Sorting Arrays in JavaScript: A Practical Guide

Sep 4, 2026·22 min read

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, use toSorted() 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:

RequirementMethodComparator or ruleExample
Change the source arraysort()Pass the required comparatortasks.sort(compareTasks)
Preserve the source arraytoSorted()Pass the required comparatortasks.toSorted(compareTasks)
NumbersEither methodSubtract finite values(a, b) => a - b
DatesEither methodCompare valid timestampsa.getTime() - b.getTime()
Machine stringsEither methodDefault ordering may be enoughcodes.toSorted()
User-facing stringsEither methodReuse an Intl.Collatorcollator.compare(a, b)
ObjectsEither methodExtract a key, then compare it(a, b) => a.estimate - b.estimate
Missing or invalid valuesEither methodApply an explicit placement policymissing values last
Multiple object keysEither methodChain comparatorsstatus, 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.

A shallow copy has two containerssource array01ordered array01same Task Aobjectsame Task Bobject
A shallow copy duplicates the array container, not the objects stored inside it.

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:

The sign controls the placementcompare(a, b)0+a goes firstabkeep orderabb goes firstba
The comparator’s sign determines which value occupies the earlier position.
  • A negative result places a before b.
  • A positive result places a after b.
  • Zero or NaN treats 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.

Equal keys still have an ordersourceAkey 5Bkey 5Ckey 3sort ↑, reversedescending compareBACtie flippedABCtie preserved
Reversing positions also reverses ties; a descending comparator preserves them.

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.

Natural ordering finds number chunksReport2Report102 < 10numbersReport 2 → Report 10
Natural ordering recognizes the numeric chunks inside otherwise textual labels.

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:

Tie-breakers form a gated chaincompare statusnonzerodecided0: tiecompare duedatenonzerodecided0: tiecompare titlenonzerodecided0: tiekeep sourceorder
A tie-breaker chain continues downward only while comparisons remain tied.
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.

A hole is not an undefined valuesparsevalueundefinedholesort()toSorted()hole stays absentvalueundef.holehole becomes a valuevalueundef.undef.
For sparse arrays, 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 a belongs before b and b before c, then a belongs before c.

Break those rules and the requested order contradicts itself. Different calls can demand incompatible positions, so the result is not dependable.

A contradictory order forms a cycle0 before 11 before 22 before 0012no firstitemis possible
A cyclic comparator has no possible first element, so it cannot define a dependable order.

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.

Frequently asked questions

Does JavaScript sort change the original array?
Yes. Array.prototype.sort() changes the original array and returns that same array. Use toSorted() when you need a new sorted array while preserving the source.
How do I sort numbers correctly in JavaScript?
Pass a numeric comparator such as (a, b) => a - b for ascending order or (a, b) => b - a for descending order. Without one, sort() converts the numbers to strings and compares their UTF-16 code units.
How do I sort objects by multiple properties?
Compare the first property, return its result when it is not zero, and continue to the next property only for a tie. This creates deterministic tie-breakers while preserving the original order when every compared property is equal.
How should null and invalid dates be sorted?
Choose an explicit application policy, such as placing missing and invalid values last. Test for null, undefined, NaN, or an invalid timestamp before subtracting values because a comparator result of NaN is treated like zero.