Sorting Arrays in JavaScript: A Practical Guide
Sorting an array starts with two choices: whether the original array may change, and what value from each element should determine its position.
Use
sort()when changing the source array is intentional, usetoSorted()when it is not, and pass a comparator that handles the data type, missing values, and tie-breakers explicitly.
The Short Answer: Pick the Right Sort
A sort key is the value used to order an element. For a list of tasks, the key might be estimate, due, title, or owner.name.
A comparator is a function that receives two elements, a and b, and decides which one comes first. The method controls mutation; the comparator controls order.
Here is the compact decision table:
| Requirement | Method | Comparator or rule | Example |
|---|---|---|---|
| Change the source array | sort() | Pass the required comparator | tasks.sort(compareTasks) |
| Preserve the source array | toSorted() | Pass the required comparator | tasks.toSorted(compareTasks) |
| Numbers | Either method | Subtract finite values | (a, b) => a - b |
| Dates | Either method | Compare valid timestamps | a.getTime() - b.getTime() |
| Machine strings | Either method | Default ordering may be enough | codes.toSorted() |
| User-facing strings | Either method | Reuse an Intl.Collator | collator.compare(a, b) |
| Objects | Either method | Extract a key, then compare it | (a, b) => a.estimate - b.estimate |
| Missing or invalid values | Either method | Apply an explicit placement policy | missing values last |
| Multiple object keys | Either method | Chain comparators | status, then date, then title |
Machine strings are identifiers whose exact code-unit order matters, such as fixed-format codes. Strings people read usually need language-sensitive comparison instead.
This guide uses one task list throughout:
const tasks = [
{
title: 'Report 10',
status: 'draft',
due: new Date(2026, 8, 12),
estimate: 8,
owner: { name: 'Élodie' },
},
{
title: 'Report 2',
status: 'ready',
due: null,
estimate: NaN,
owner: { name: 'Maya' },
},
{
title: 'report 1',
status: 'draft',
due: new Date(2026, 8, 5),
estimate: 3,
owner: { name: 'elodie' },
},
{
title: 'Report 2',
status: 'draft',
due: new Date(NaN),
estimate: undefined,
owner: { name: 'Raj' },
},
];
The awkward values are deliberate. Real application arrays contain missing estimates, invalid dates, repeated titles, and names whose accents and letter case matter.
For the mechanics behind indexed collections, start with Arrays and Array methods. The rest of this article concentrates on choosing and composing the order.
sort() vs. toSorted()
Mutation means changing an existing value rather than producing a separate replacement. sort() mutates its array in place and returns a reference to that same array.
Assigning its return value to another variable does not protect the original. This example uses the finite estimates from the task list and shows both names pointing to the sorted array:
const estimates = tasks
.map(task => task.estimate)
.filter(Number.isFinite);
const ordered = estimates.sort((a, b) => a - b);
console.log(estimates.join(', '));
console.log(ordered.join(', '));
console.log(estimates === ordered);
3, 8
3, 8
true
Both variables print 3, 5, 8, and the identity comparison is true. There is one array with two references.
toSorted() returns a new sorted array and leaves its source unchanged:
const estimates = tasks
.map(task => task.estimate)
.filter(Number.isFinite);
const ordered = estimates.toSorted((a, b) => a - b);
console.log(estimates.join(', '));
console.log(ordered.join(', '));
console.log(estimates === ordered);
8, 3
3, 8
false
This time the source stays in insertion order and ordered is a different array.
The new array is a shallow copy. Its object elements are still references to the same objects, so changing ordered[0].title also changes that task through the source array. Only the outer array is copied.
Use toSorted() when the original order still has meaning, when state updates depend on a new array identity, or when a function should not change its input. Immutable Array Methods puts it beside the other copying array operations.
Check support in every target runtime before choosing toSorted(). When support is unavailable, [...tasks].sort(comparator) makes a shallow copy first and sorts that copy.
How Comparator Functions Control the Order
A comparator receives two values and returns a number:
- A negative result places
abeforeb. - A positive result places
aafterb. - Zero or
NaNtreats the pair as equal for ordering.
That contract produces the familiar numeric comparators. This example uses the finite task estimates to show both directions:
const estimates = tasks
.map(task => task.estimate)
.filter(Number.isFinite);
const ascending = estimates.toSorted((a, b) => a - b);
const descending = estimates.toSorted((a, b) => b - a);
console.log(ascending.join(', '));
console.log(descending.join(', '));
3, 8
8, 3
For ascending order, 3 - 8 is negative, so 3 moves before 8. Swapping the subtraction gives descending order.
Without a comparator, sort() converts non-undefined elements to strings and compares their UTF-16 code units. The task estimates can demonstrate why that default fails for ordinary numbers when expressed in tenths:
const estimatesInTenths = tasks
.map(task => task.estimate)
.filter(Number.isFinite)
.map(estimate => estimate * 10);
console.log(estimatesInTenths.toSorted().join(', '));
console.log(estimatesInTenths.toSorted((a, b) => a - b).join(', '));
30, 80
30, 80
With these particular values both orders happen to agree, but the default still compares "30" and "80" by their code units rather than their numeric values.
Dates need numeric keys too. Date.prototype.getTime() returns a timestamp in milliseconds, so valid dates can use the same subtraction pattern:
const earlierFirst = (a, b) => a.getTime() - b.getTime();
const laterFirst = (a, b) => b.getTime() - a.getTime();
const dates = tasks
.map(task => task.due)
.filter(date =>
date instanceof Date && Number.isFinite(date.getTime())
);
console.log(dates.toSorted(earlierFirst).map(date => date.getDate()).join(', '));
console.log(dates.toSorted(laterFirst).map(date => date.getDate()).join(', '));
5, 12
12, 5
An invalid date returns NaN from getTime(). Subtracting that value also produces NaN, which the sort treats like equality. Guard it before subtraction.
Sorting ascending and then calling reverse() looks like a shortcut for descending order, but reverse() reverses every position, including the relative order of equal-key elements. Use a descending comparator when ties must keep their existing order.
Sorting Strings People Actually Read
Default string ordering compares UTF-16 code units. It does not apply the language and numeric rules a person expects when reading names or filenames.
String.prototype.localeCompare() performs a language-sensitive comparison:
const owners = tasks.map(task => task.owner.name);
const ordered = owners.toSorted((a, b) =>
a.localeCompare(b, 'en', { sensitivity: 'accent' })
);
The sensitivity: 'accent' option ignores letter case while preserving accent differences. elodie and Élodie therefore remain distinct keys, while Zoe and zoe compare as equal.
When the same rules sort many strings, create one Intl.Collator and reuse its compare function:
const titleCollator = new Intl.Collator('en', {
sensitivity: 'accent',
numeric: true,
});
const titles = tasks.map(task => task.title);
const ordered = titles.toSorted(titleCollator.compare);
console.log(ordered.join(' | '));
report 1 | Report 2 | Report 2 | Report 10
numeric: true compares digit sequences numerically, so 2 comes before 10. This is often called natural ordering, and it fits filenames, numbered chapters, ticket labels, and the task titles in the running dataset.
Locale choice is part of the requirement, not decoration. Pass the locale used by the interface or the data. The same accented pair can have a different appropriate position under another language’s collation rules.
Use default ordering for machine strings only when UTF-16 ordering is the intended contract. Use localeCompare() for an occasional user-facing comparison, and reuse Intl.Collator when one rule orders an entire collection.
Sorting Objects by One or More Properties
Object sorting begins by extracting a key from each object. For one finite numeric property, the comparator stays short:
const ordered = tasks
.filter(task => Number.isFinite(task.estimate))
.toSorted((a, b) => a.estimate - b.estimate);
console.log(ordered.map(task => task.title).join(' | '));
report 1 | Report 10
A nested property works the same way. Extract owner.name, then hand both names to a collator:
const ownerCollator = new Intl.Collator('en', {
sensitivity: 'accent',
});
const byOwner = (a, b) =>
ownerCollator.compare(a.owner.name, b.owner.name);
Missing values need a declared policy. JavaScript does place undefined array elements at the end without passing them to the comparator, but that rule does not place an object whose property is undefined. The comparator still receives the object.
Here is a reusable null-last wrapper. compareNullLast accepts a comparator for present values and returns a new comparator that handles null and undefined first:
const compareNullLast = (comparePresent) => (a, b) => {
const aMissing = a === null || a === undefined;
const bMissing = b === null || b === undefined;
if (aMissing && bMissing) return 0;
if (aMissing) return 1;
if (bMissing) return -1;
return comparePresent(a, b);
};
For example, construct a title comparator and pass it to toSorted() like any other comparator:
const compareOptionalTitles = compareNullLast((a, b) =>
a.localeCompare(b, 'en', { numeric: true })
);
const orderedByTitle = tasks.toSorted((a, b) =>
compareOptionalTitles(a.title, b.title)
);
This is an application policy. You could place missing values first instead, but the choice must be visible in the comparator.
A tie-breaker is a later comparison used only when an earlier key compares equal. The canonical implementation below sorts the complete task dataset by status, then valid due date, then natural title order. It also includes copy-ready policies for finite numbers, null, undefined, NaN, and invalid dates:
const tasks = [
{
title: 'Report 10',
status: 'draft',
due: new Date(2026, 8, 12),
estimate: 8,
owner: { name: 'Élodie' },
},
{
title: 'Report 2',
status: 'ready',
due: null,
estimate: NaN,
owner: { name: 'Maya' },
},
{
title: 'report 1',
status: 'draft',
due: new Date(2026, 8, 5),
estimate: 3,
owner: { name: 'elodie' },
},
{
title: 'Report 2',
status: 'draft',
due: new Date(NaN),
estimate: undefined,
owner: { name: 'Raj' },
},
];
const titleCollator = new Intl.Collator('en', {
sensitivity: 'accent',
numeric: true,
});
const statusOrder = new Map([
['ready', 0],
['draft', 1],
]);
const compareNullLast = (comparePresent) => (a, b) => {
const aMissing = a === null || a === undefined;
const bMissing = b === null || b === undefined;
if (aMissing && bMissing) return 0;
if (aMissing) return 1;
if (bMissing) return -1;
return comparePresent(a, b);
};
const compareFiniteNumbersLast = (a, b) => {
const aInvalid = !Number.isFinite(a);
const bInvalid = !Number.isFinite(b);
if (aInvalid && bInvalid) return 0;
if (aInvalid) return 1;
if (bInvalid) return -1;
return a - b;
};
const compareDatesLast = compareNullLast((a, b) =>
compareFiniteNumbersLast(a.getTime(), b.getTime())
);
const compareTasks = (a, b) => {
const unknownStatusRank = statusOrder.size;
const byStatus =
(statusOrder.get(a.status) ?? unknownStatusRank) -
(statusOrder.get(b.status) ?? unknownStatusRank);
if (byStatus !== 0) return byStatus;
const byDate = compareDatesLast(a.due, b.due);
if (byDate !== 0) return byDate;
return titleCollator.compare(a.title, b.title);
};
const orderedTasks = tasks.toSorted(compareTasks);
const orderedEstimates = tasks.toSorted((a, b) =>
compareFiniteNumbersLast(a.estimate, b.estimate)
);
console.log(orderedTasks.map(task => task.title).join(' | '));
console.log(
orderedEstimates
.map(task => String(task.estimate))
.join(' | ')
);
console.log(tasks.map(task => task.title).join(' | '));
Report 2 | report 1 | Report 10 | Report 2
3 | 8 | NaN | undefined
Report 10 | Report 2 | report 1 | Report 2
The ready task comes first. Among draft tasks, valid dates come before invalid or missing dates. The estimate ordering puts finite numbers first and treats both NaN and undefined as invalid values that retain their original relative order.
Stable sorting makes that last detail dependable. When a comparator returns zero, equal elements retain their relative order from the source. If status, date, and title all compare equal, the canonical comparator returns zero and leaves that pair alone.
This is also why tie-breakers should represent real requirements. Add an ID comparison only when the product needs ID order. Otherwise, stability preserves the existing sequence.
For broader work with object keys and grouped records, Key-Value Pairs in JavaScript: Objects vs Maps covers the stores used by lookup tables such as statusOrder. JavaScript Fundamentals collects the underlying array, object, comparison, and function rules as an offline reference.
Edge Cases and Comparator Tests
undefined values and empty slots have special array-level behavior. sort() moves undefined elements to the end without passing them to the comparator, and preserves empty slots after those elements. On a sparse array, toSorted() reads empty slots as undefined, so its result is dense and those values appear at the end.
sort() preserves holes while toSorted() turns holes into undefined.That built-in placement does not solve missing object properties, null, NaN, or invalid dates. The canonical comparators detect those values before subtraction and place them according to the chosen policy.
A reliable comparator also follows four rules:
- Purity means it does not change either argument or outside state.
- Reflexivity means comparing a value with itself returns zero.
- Anti-symmetry means reversing the arguments reverses the sign.
- Transitivity means if
abelongs beforebandbbeforec, thenabelongs beforec.
Break those rules and the requested order contradicts itself. Different calls can demand incompatible positions, so the result is not dependable.
This test checks every ordered pair and triple from a small representative set, along with unchanged input. The malformed comparators show that the checks reject asymmetry and non-transitivity:
const compareNumbers = (a, b) => a - b;
const source = tasks.slice(0, 3).map((task, index) => index);
const snapshot = [...source];
const ordered = source.toSorted(compareNumbers);
const comparatorIsValid = (compare, values) =>
values.every(a => compare(a, a) === 0) &&
values.every(a => values.every(b =>
Number.isFinite(compare(a, b)) &&
Math.sign(compare(a, b)) === -Math.sign(compare(b, a))
)) &&
values.every(a => values.every(b => values.every(c =>
!(compare(a, b) <= 0 && compare(b, c) <= 0) ||
compare(a, c) <= 0
)));
const asymmetric = (a, b) => a === b ? 0 : 1;
const nonTransitive = (a, b) => {
if (a === b) return 0;
return (a + 1) % 3 === b ? -1 : 1;
};
console.log(ordered.join(', '));
console.log(source.join(', ') === snapshot.join(', '));
console.log(comparatorIsValid(compareNumbers, source));
console.log(comparatorIsValid(asymmetric, source));
console.log(comparatorIsValid(nonTransitive, source));
0, 1, 2
true
true
false
false
These tests inspect properties of the comparator instead of one large expected array. A comparator can pass one example by accident and still fail when arguments arrive in another order.
JavaScript does not guarantee a particular sorting algorithm or a fixed time or space complexity for sort(). The implementation depends on the engine. Write against the comparator contract, stable ordering, and the observable behavior of sort() or toSorted(), not against an assumed internal algorithm.
The comparison operators used inside simpler comparators are covered in Comparisons.