jsguides

Array.prototype.sort()

sort([compareFn])

Array.prototype.sort() is the built-in way to reorder an array’s elements. It mutates the array in place and returns a reference to that same array, not a copy. That second part trips up a lot of people.

The sort is stable as of ES2019, so elements that compare as equal keep their original order. Without a compare function, every element is converted to a string and sorted by UTF-16 code unit values, which is almost never what you want for numbers.

Syntax

arr.sort();
arr.sort(compareFn);

Parameters

compareFn (optional)

A function that defines the sort order. Called as compareFn(a, b) where a and b are two elements from the array. The spec guarantees both arguments are defined - compareFn is never invoked with undefined as either argument.

The return value controls the ordering:

Return valueMeaning
Negativea should come before b
Positivea should come after b
0 or NaNa and b are equal (relative order preserved by stability)

For numbers, (a, b) => a - b sorts ascending. It is by far the most common comparator you’ll write, and you will write it more often than you’d like.

If you omit compareFn, every element is converted to a string and sorted by UTF-16 code unit. undefined values are pushed to the end of the array, and compareFn is not called at all.

Return value

The same array reference, now sorted. This is the most common bug with sort():

const numbers = [3, 1, 4, 1, 5];
const sorted = numbers.sort((a, b) => a - b);
// numbers and sorted both reference the same array
// both are [1, 1, 3, 4, 5]

sorted[0] = 999;
console.log(numbers[0]);
// 999

If you need a sorted copy, clone first. All three options below produce identical results for the same comparator, so the choice is mostly about your target environment. Three common options, oldest to newest:

const numbers = [3, 1, 4, 1, 5];

// 1. spread into a new array
const a = [...numbers].sort((a, b) => a - b);

// 2. Array.from
const b = Array.from(numbers).sort((a, b) => a - b);

// 3. Array.prototype.toSorted() (ES2023, Node 20+, modern browsers)
const c = numbers.toSorted((a, b) => a - b);

toSorted() is the cleanest of the three. It does exactly what sort() does but returns a new array, so the original stays untouched.

Default sort order is lexicographic

The default behavior sorts by string. That breaks the moment you sort numbers:

const nums = [1, 30, 4, 21, 100000];
nums.sort();
console.log(nums);
// [1, 100000, 21, 30, 4]

Each number is converted to a string, then sorted by UTF-16 code unit. "1" < "100000" < "21" < "30" < "4" is true in code-unit order even though it is nonsense as a numeric ordering. Every beginner trips on this exactly once.

The same gotcha applies to mixed types - everything gets stringified before comparison:

const values = [80, 9, 700, "abc", "40"];
values.sort();
console.log(values);
// ['40', 700, 80, 9, 'abc']

"40" < "700" < "80" < "9" < "abc" by code unit, and the integers in the array keep their original types - only the comparison is string-based.

Sorting numbers

The classic fix is a numeric comparator:

const numbers = [40, 1, 5, 200, 25];
numbers.sort((a, b) => a - b);
console.log(numbers);
// [1, 5, 25, 40, 200]

(a, b) => a - b returns a negative number when a < b, positive when a > b, and 0 when they are equal. Descending is just the inverse:

numbers.sort((a, b) => b - a);
console.log(numbers);
// [200, 40, 25, 5, 1]

One subtle gotcha: if either value is NaN, a - b returns NaN, and the spec treats NaN as “equal” for ordering. The sort still completes - NaN values cluster together - but you cannot predict exactly where in the array they end up. Strip them out first if the order matters.

Sorting objects

For arrays of objects, the comparator reads a property. Two common patterns:

const items = [
  { name: "Edward", value: 21 },
  { name: "Sharpe", value: 37 },
  { name: "And", value: 45 },
  { name: "Magnetic", value: 13 },
];

// sort by numeric property (ascending)
items.sort((a, b) => a.value - b.value);

// sort by string property, case-insensitive
items.sort((a, b) => {
  const nameA = a.name.toUpperCase();
  const nameB = b.name.toUpperCase();
  if (nameA < nameB) return -1;
  if (nameA > nameB) return 1;
  return 0;
});

The case-insensitive version uppercases both sides first, so "apple" and "Apple" end up next to each other. For real internationalization, use localeCompare instead.

Locale-aware sorting

For text with accented characters, ligatures, or non-ASCII scripts, the default UTF-16 ordering produces odd results. The right tool is String.prototype.localeCompare():

const words = ["réservé", "premier", "communiqué", "café", "adieu", "éclair"];
words.sort((a, b) => a.localeCompare(b));
console.log(words);
// ['adieu', 'café', 'communiqué', 'éclair', 'premier', 'réservé']

localeCompare respects the user’s locale, handles diacritics, and groups accented characters near their unaccented siblings. For options like numeric: true (so "item2" sorts before "item10") and sensitivity: "base" (so accents are ignored), see String.prototype.localeCompare().

The default UTF-16 comparison also misbehaves on surrogate pairs. A single CJK character above \uFFFF is encoded as two code units, and those two units get compared individually. The localeCompare approach sidesteps that entirely.

Stability (ES2019)

The spec has required sort() to be stable since ECMAScript 2019. Equal elements keep their original relative order. Before that, V8 used an unstable QuickSort; Firefox and Safari were already stable.

This is a real feature, not a footnote. It means you can rely on a previous secondary sort being preserved when you sort by a primary key. The classic MDN example:

const students = [
  { name: "Alex", grade: 15 },
  { name: "Devlin", grade: 15 },
  { name: "Eagle", grade: 13 },
  { name: "Sam", grade: 14 },
];

students.sort((a, b) => a.grade - b.grade);
console.log(students);
// [
//   { name: 'Eagle', grade: 13 },
//   { name: 'Sam', grade: 14 },
//   { name: 'Alex', grade: 15 },
//   { name: 'Devlin', grade: 15 }
// ]

Alex and Devlin both have grade 15. The sort preserved their original relative order - Alex came before Devlin in the source, and the sort kept that. A stable sort is also what makes the Schwartzian transform (later in this article) work without losing ties.

Non-mutating alternatives

sort() mutates the original array. Three ways to get a sorted copy, in order from oldest to newest:

const numbers = [3, 1, 4, 1, 5];

// 1. spread
const a = [...numbers].sort((a, b) => a - b);

// 2. Array.from
const b = Array.from(numbers).sort((a, b) => a - b);

// 3. Array.prototype.toSorted() (ES2023)
const c = numbers.toSorted((a, b) => a - b);

console.log(numbers);
// [3, 1, 4, 1, 5]   ← original untouched in all three

toSorted() is part of the ES2023 family of non-mutating array methods, alongside toReversed(), toSpliced(), and with(). If you target modern environments, it is the right pick. See Array.prototype.toSorted().

The comparator contract

A compareFn should be a well-formed ordering function. The spec lists five rules: pure, stable, reflexive, anti-symmetric, transitive. Violate any of them and different engines will return different results on the same input.

A common broken comparator:

const broken = (a, b) => (a > b ? 1 : 0);
[3, 1, 4, 1, 5, 9].sort(broken);
// V8 / JavaScriptCore: [3, 1, 4, 1, 5, 9]   -  unchanged
// SpiderMonkey:        [1, 1, 3, 4, 5, 9]   -  ascending

Same input, different output, depending on the engine. The bug is that broken is not anti-symmetric - it returns 0 for any pair where a is not strictly greater than b, including pairs where a < b. The fix is to always return -1, 0, or 1 (or a consistently-signed number):

const ok = (a, b) => {
  if (a < b) return -1;
  if (a > b) return 1;
  return 0;
};

The spec is also explicit that compareFn must not mutate the array or rely on any external mutable state. The algorithm decides when and how often to call it, and a comparator that fails that contract will eventually fail in production.

Sparse arrays and undefined

sort() handles sparse arrays and undefined values in a specific way: they all move to the end. Without compareFn, undefined is pushed to the end and the rest are sorted as strings:

console.log(["a", "c", , "b"].sort());
// ['a', 'b', 'c', empty]

console.log([, undefined, "a", "b"].sort());
// ['a', 'b', undefined, empty]

With a compareFn, undefined values still go to the end - and compareFn is never invoked with undefined as either argument. Empty holes are placed after any explicit undefined values.

This rarely matters in practice, but it can surprise you when filtering or mapping into a new array accidentally produces holes. The result will have those holes at the end, where the rest of your code might not expect them.

Performance: the Schwartzian transform

compareFn is called O(n log n) times. If each call is expensive - parsing a date, normalizing a string, hitting a regex - the sort will be slow. The Schwartzian transform solves this: extract the sortable key once per element, sort by the cheap numeric key, then unmap.

const data = ["2024-01-15", "2023-07-04", "2024-12-31", "2023-03-22"];

// 1. attach the parsed timestamp as a sortable key
const mapped = data.map((v, i) => ({ i, key: Date.parse(v) }));

// 2. sort by the cheap numeric key
mapped.sort((a, b) => a.key - b.key);

// 3. unmap back to the original strings
const result = mapped.map(({ i }) => data[i]);
console.log(result);
// ['2023-03-22', '2023-07-04', '2024-01-15', '2024-12-31']

Date.parse runs once per element instead of O(n log n) times. The same shape works for any expensive per-element computation. The trick is to pre-compute the key.

The Schwartzian transform is also where the stability guarantee earns its keep: ties on the parsed key preserve the original order of the input strings, so equal timestamps keep their source sequence. See Array.prototype.map() for the building block.

See Also