Quick Answer

Array.prototype.sort with no arguments converts every element to a string and compares UTF-16 code units, so [10, 9, 1] becomes [1, 10, 9]. Pass a comparator that returns a negative number, zero or a positive number: (a, b) => a - b for ascending numbers. sort also mutates the array in place and returns the same reference, which breaks React state updates. Use toSorted or [...arr].sort() when you need a copy.

The default sort is a string sort

This is the result that makes no sense until it does:

console.log([10, 9, 1].sort());        // [1, 10, 9]
console.log([100, 25, 9, 80].sort());  // [100, 25, 80, 9]

With no comparator, sort converts every element to a string and compares them by UTF-16 code unit, character by character, exactly like dictionary order. '100' comes before '25' because '1' is before '2'. '9' goes last because '9' is after every other first character in that list. The array of numbers was never compared as numbers at all.

The fix is a comparator:

console.log([10, 9, 1].sort((a, b) => a - b));  // [1, 9, 10]   ascending
console.log([10, 9, 1].sort((a, b) => b - a));  // [10, 9, 1]   descending

This bites hardest with data that is numeric but stored as text. Marks read from a CSV, prices pulled from an API as "1299", roll numbers, page counts: all of them look numeric on screen and sort like words. Convert first, or compare with Number(a) - Number(b).

Two more default-sort behaviours worth knowing. undefined values are always moved to the end and your comparator is never called for them, so you cannot sort them into position:

console.log([3, undefined, 1].sort((a, b) => a - b)); // [1, 3, undefined]

And empty slots in a sparse array go after even the undefined entries. Both rules mean an array with gaps will not sort the way you drew it on paper. Filter the array before sorting if missing values need to appear anywhere other than the end.

One last quirk with real consequences: if a comparator returns NaN, the specification says treat it as zero, meaning "these two are equal". So ['b', 'a'].sort((a, b) => a - b) silently leaves the array untouched instead of throwing. A sort that appears to do nothing is almost always a comparator returning NaN.

What a comparator must return

The contract is three cases. Return a negative number to put a before b, zero to treat them as equal, and a positive number to put a after b. The exact magnitude is irrelevant, only the sign matters.

Which is why this common attempt is broken:

// Wrong: returns true or false, which coerce to 1 and 0
arr.sort((a, b) => a > b);

The engine never receives a negative value, so it can never learn that a should come before b. It cannot distinguish "after" from "equal". The result depends on the engine's algorithm and even on the array length, and it is usually partly sorted, which is worse than obviously wrong because it passes a quick eyeball test. For values that are not numbers, return an explicit sign instead:

arr.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));

For strings, the default comparison is by code unit, which puts every capital letter before every lowercase one because 'Z' is U+005A and 'a' is U+0061:

console.log(['Zara', 'anita', 'Bhavna'].sort());
// ['Bhavna', 'Zara', 'anita']

console.log(['Zara', 'anita', 'Bhavna'].sort((a, b) => a.localeCompare(b)));
// ['anita', 'Bhavna', 'Zara']

localeCompare also handles accented characters and non-Latin scripts sensibly, and it takes options. The numeric option fixes the classic file-name problem:

console.log(['item10', 'item9', 'item2'].sort());
// ['item10', 'item2', 'item9']

console.log(['item10', 'item9', 'item2'].sort(
  (a, b) => a.localeCompare(b, undefined, { numeric: true })
));
// ['item2', 'item9', 'item10']

For a long list, calling localeCompare per comparison is wasteful. Build an Intl.Collator once and pass its compare method, which is the same logic with the locale data resolved a single time:

const collator = new Intl.Collator('en-IN', { numeric: true, sensitivity: 'base' });
names.sort(collator.compare);

sort changes the original array

sort sorts in place and returns a reference to the same array, not a new one. That trips up anyone used to map and filter, which both return copies.

const marks = [88, 72, 95];
const sorted = marks.sort((a, b) => b - a);

console.log(marks);            // [95, 88, 72]  the original changed
console.log(sorted === marks); // true          same array

If marks was a prop, a module-level constant or something another part of the page is rendering, you have just reordered it everywhere. The same applies to reverse.

In React this produces a bug that looks like a rendering failure:

// Broken: same reference, so React sees no change and skips the re-render
setStudents(students.sort((a, b) => b.marks - a.marks));

// Correct: sort a copy, pass a new reference
setStudents([...students].sort((a, b) => b.marks - a.marks));

The state has genuinely been mutated in the broken version, so the data is reordered, but because the reference is unchanged React's bailout check sees the same object and does not re-render. The list on screen updates only when some unrelated state change forces a render, which is exactly the kind of intermittent bug that eats an afternoon.

Modern JavaScript has a non-mutating version, toSorted, part of the change-by-copy array methods:

const marks = [88, 72, 95];
const ranked = marks.toSorted((a, b) => b - a);

console.log(marks);  // [88, 72, 95] untouched
console.log(ranked); // [95, 88, 72]

Its siblings are toReversed, toSpliced and with. They are available in current browsers and recent Node, but if you must support older environments, check your browser support target or keep using [...arr].sort(), which works everywhere and costs one shallow copy. Note the copy is shallow, so the objects inside are still shared, which is fine for sorting because sorting only reorders references.

Stability and sorting by more than one field

A sort is stable if elements the comparator calls equal keep their original relative order. JavaScript's sort is required to be stable, which is what makes multi-field sorting by repeated passes work reliably.

const rows = [
  { name: 'Anita', city: 'Pune',   marks: 88 },
  { name: 'Rohit', city: 'Nagpur', marks: 92 },
  { name: 'Kabir', city: 'Pune',   marks: 88 },
];

rows.sort((a, b) => a.name.localeCompare(b.name)); // pass 1: by name
rows.sort((a, b) => b.marks - a.marks);            // pass 2: by marks desc

// Rohit 92, then Anita 88, then Kabir 88
// Anita stays before Kabir because the sort is stable

Two passes are readable but do twice the work. A single comparator that falls through to the next field is usually clearer and faster. The trick is the || chain, which works precisely because a tie returns zero and zero is falsy:

const byMarksThenName = (a, b) =>
  b.marks - a.marks || a.name.localeCompare(b.name);

rows.sort(byMarksThenName);

Read it as: try marks descending, and if that is a tie, break it by name ascending. Chain as many fields as you need. This is exactly how you build a leaderboard where equal scores are listed alphabetically rather than in whatever order the database returned.

Your comparator must also be consistent: if it says a before b and b before c, it has to say a before c, and it must give the same answer every time for the same pair. A comparator that reads mutable state, or one built on Math.random(), breaks that. Here the languages differ, and it matters if you also write Java: Arrays.sort in Java may throw IllegalArgumentException: Comparison method violates its general contract, while JavaScript engines simply return an arbitrary order with no error at all. A silently scrambled list is harder to notice than an exception.

If you want a genuinely shuffled array, do not use sort(() => Math.random() - 0.5). It produces a biased, engine-dependent distribution. Use a Fisher-Yates shuffle.

Recipes for real data

Dates. ISO strings such as '2026-08-06' sort correctly as plain strings because the format is fixed width and big-endian. Any other format, and dates that arrive as Date objects, need a numeric comparison:

posts.sort((a, b) => new Date(b.publishedDate) - new Date(a.publishedDate));

Subtracting Date objects works because they coerce to milliseconds. It does allocate two Date objects per comparison, though, so for a long list precompute the timestamp once.

Expensive keys. When the sort key costs something to compute, do not recompute it inside every comparison. Decorate, sort, then undecorate:

const decorated = students.map((s) => ({
  item: s,
  key: s.name.trim().toLowerCase(),
}));

decorated.sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
const result = decorated.map((d) => d.item);

The comparator runs many times per element, so the key is built once instead of on every comparison.

Rupee amounts stored as text. Strip the formatting before comparing, and never compare the display string:

const toPaise = (s) => Math.round(Number(String(s).replace(/[^0-9.]/g, '')) * 100);
fees.sort((a, b) => toPaise(a.amount) - toPaise(b.amount));

DOM nodes. document.querySelectorAll returns a NodeList, which has no sort. Convert first with Array.from(nodes) or [...nodes].

Sorting during render. Sorting a large list inside a React component body runs on every render. Wrap it in useMemo keyed on the data and the sort field, and remember to sort a copy so you are not mutating props:

const sorted = useMemo(
  () => [...students].sort(byMarksThenName),
  [students]
);

Finally, if the data comes from a database and there are more rows than you show at once, sort in SQL with ORDER BY and paginate. Fetching ten thousand rows to the browser so JavaScript can sort them and display twenty is the wrong layer to solve the problem in.

Frequently Asked Questions

Why does [10, 9, 1].sort() give [1, 10, 9]? Because the default sort with no comparator converts every element to a string and compares UTF-16 code units, which is dictionary order. '1' sorts before '9', so '10' lands before '9'. The elements are never compared as numbers. Pass a comparator such as (a, b) => a - b for ascending numeric order, and use Number(a) - Number(b) when the values are numeric strings coming from an API or a CSV.
What is the difference between sort and toSorted? sort reorders the array in place and returns a reference to that same array, so the original is modified. toSorted leaves the original untouched and returns a new array, which is what you usually want in React and in any code that shares data. toSorted is available in current browsers and recent Node; if you support older environments, [...arr].sort(comparator) achieves the same thing with a shallow copy.
Is JavaScript's sort stable? Yes, the specification requires a stable sort, so elements your comparator treats as equal keep their original relative order. That is what lets you sort by a secondary field first and a primary field second and get a correct multi-field result. It is still usually better to write one comparator with a fallback chain using ||, because it does a single pass and states the ordering rules in one place.
Why does my comparator (a, b) => a > b give a partly sorted array? Because it returns a boolean, which coerces to 1 for true and 0 for false, so the engine never receives a negative value and cannot tell 'a comes first' from 'a and b are equal'. The result varies by engine and by array length. Return an explicit sign instead, using a ternary that yields -1, 1 or 0, or subtract for numbers, or use localeCompare for strings.
How do I sort names correctly, including Indian names with different scripts? Use localeCompare or Intl.Collator rather than the default comparison, because the default orders by raw code units and puts every capital letter before every lowercase one. Intl.Collator resolves the locale data once and is the better choice for long lists: new Intl.Collator('en-IN', { sensitivity: 'base' }) then pass collator.compare to sort. Add { numeric: true } when names or labels contain digits.