cf.completefrontend
SORTING · FOUNDATION

Sorting with comparators

Tell JavaScript's sort how to order items with a compare function that returns a negative, zero, or positive number.

Time O(n log n) comparisonsSpace O(n)19 practice problemsStep-through picture

The idea

Most real sorting in JavaScript is a call to array.sort(compare). You don't write the sorting algorithm; you write the rule. The rule is a function compare(a, b) that returns a negative number if a should come first, a positive number if b should come first, and 0 if they are equal for sorting purposes.

Without a compare function, sort turns every item into a string and orders them letter by letter. That is why [10, 9, 1].sort() gives [1, 10, 9]: the string "10" comes before "9" because "1" comes before "9". For numbers, always pass (a, b) => a - b.

Since ES2019, sort must be stable: items that compare equal keep their original order. That lets you build multi-key sorts (by priority, then by date, then by name) with one compare function that checks each key in turn, or with several stable sorts.

An everyday example

Giving a helper a rule for ordering files

You hand a helper a box of folders and say: "Put urgent ones first. Among folders with the same urgency, earlier due dates first. If those match too, go alphabetically." The helper does the sorting work; you only gave the rule. Every time the helper holds up two folders and asks "which first?", the rule answers.

How the example maps to the code
In the exampleIn the code
"Which of these two goes first?"compare(a, b)
"The left one"A negative return value
"The right one"A positive return value
"They're tied, keep them as they were"0 (and a stable sort keeps their order)
Checking urgency, then date, then nameComparing keys one after another, returning at the first difference

From the slow way to sorting with comparators

  1. Every sort in this category compares items with < or >. That works for numbers. But real data is objects: products, tasks, rows in a table. < on two objects compares their string forms, like "[object Object]", which says nothing about which product should come first.
  2. The fix is to separate the two jobs. The sorting algorithm decides which pairs to compare and how to move items. A small function decides, for one pair, which item goes first. Pass that function to the sort, and the same fast, tested algorithm can order anything.
  3. The function needs to answer three ways, not two: a first, b first, or "equal for sorting". A number does that: negative, positive, or 0. That is the contract of compare(a, b). The "equal" answer matters, because a stable sort keeps equal items in their original order.
  4. With that contract, sorting by several keys is a chain of checks: compare the most important key, and only if it is equal move to the next, ending with 0 or a final tie-breaker. Writing one comparator like that and passing it to the built-in sort on a copy of the list is sorting with comparators.

How it works

  1. Copy the array if the original order must be kept: [...items].sort(...) or items.toSorted(...) (ES2023).
  2. Write compare(a, b). Start with the most important key. If the keys differ, return their difference (for numbers) or -1/1.
  3. If the first key is equal, move on to the next key, and so on.
  4. For strings meant for people, compare with a.localeCompare(b) or an Intl.Collator, which know about case and accents.
  5. Return 0 when every key is equal; the stable sort keeps those items in their original order.

Watch it run

This picture runs the code below on a real input. Press Step to move one step at a time, or Play to watch. The highlighted line is the one that just ran. Change the input to see other cases.

Loading the picture…
See every step at onceThe same run, laid out side by side
Drawing every step…

The code

Point at a note to light up the lines it explains.

  1. function sortProducts(products) {
  2. return [...products].sort((a, b) => {
  3. if (a.inStock !== b.inStock) {
  4. return a.inStock ? -1 : 1;
  5. }
  6. if (a.price !== b.price) {
  7. return a.price - b.price;
  8. }
  9. return a.name.localeCompare(b.name);
  10. });
  11. }
  12. const prices = [10, 9, 1];
  13. prices.sort(); // [1, 10, 9]: compared as strings
  14. prices.sort((a, b) => a - b); // [1, 9, 10]

How fast it is

TimeO(n log n) comparisons
Extra memoryO(n)

The engine's sort (TimSort in V8) makes O(n log n) calls to your comparator. The copy, and the merge buffer inside the sort, use O(n) memory.

  • Best caseO(n)

    The list is already in comparator order. V8's TimSort finds one run with n - 1 comparator calls and stops.

  • Average caseO(n log n)

    A list in random order takes a bit under n × log₂ n comparator calls. Sorting 10,000 random numbers in Node.js took about 120,000 calls.

  • Worst caseO(n log n)

    No input is worse: TimSort is O(n log n) comparisons on any list. But each call costs whatever your comparator costs, so a slow comparator multiplies the total.

Common mistakes

  • Calling numbers.sort() without a comparator. Numbers are sorted as strings, so [10, 9, 1] becomes [1, 10, 9].
  • Returning a boolean, as in (a, b) => a > b. It never returns a negative number, so the order is unreliable.
  • Sorting React state or props in place with sort. It mutates the array; use [...items].sort(...) or toSorted.
  • Subtracting values that are not numbers, such as a.name - b.name, which gives NaN. Use localeCompare for strings.
  • Doing expensive work inside the comparator, such as parsing a date string, which runs O(n log n) times. Compute the keys once first.

When to use it

Look for these clues in a problem:

  • Items must be ordered by a custom rule: by a field, by several fields, by a fixed ranking like high > medium > low.
  • The items are numbers and the built-in sort is being called without a compare function (a likely bug).
  • Strings need human-friendly order: case-insensitive, accented letters, or numbers inside names like file10.png.
  • Ties must keep their original order.

Check yourself

Answer each one in your head first, then open it.

Why does [25, 100, 3].sort() give [100, 25, 3]?

Without a comparator, items are compared as strings, character by character. "1" comes before "2", which comes before "3", so "100" sorts first.

Why is (a, b) => a > b a broken comparator?

It returns true or false, which become 1 or 0, never a negative number. The sort can't tell "a goes first" from "equal", so the result depends on the engine.

In the trace, why did Mat end up before Desk even though both are out of stock?

Equal stock sends the comparison to the next key, price. 3 - 120 is negative, so the cheaper Mat goes first.

How many comparator calls does sorting 1,000 items take, roughly?

A bit under 10,000, since log₂ 1000 is about 10; random lists took about 8,600 in Node.js. Fewer if the data is partly sorted, and only 999 if it is fully sorted.

Practice problems

Stuck on one? Tuto is in the playground too, and gives you hints, never the answer.

Go further

You don't need these to start practicing. Open one when you want more.

Why it works

The reasoning that makes it always right

A comparator defines an order, and the sort is only correct if that order is consistent. Three rules make it so: the answer for (b, a) is the opposite sign of the answer for (a, b); if a goes before b and b before c, then a goes before c; and the same pair always gets the same answer. Every sorting algorithm's proof assumes these rules, the same way the proofs for numbers assume < behaves.

A key-by-key comparator follows the rules if each key does. Two items are compared on the first key where they differ, which is how words are ordered in a dictionary: by the first letter, then the second, and so on. a.price - b.price follows the rules for numbers, localeCompare follows them for strings, and the boolean check puts true before false consistently.

Stability finishes the job. Since ES2019, sort must keep items that compare as 0 in their original order. So when the comparator returns 0 for two items, the result is fully determined by the input order, and two engines that call the comparator in different orders still return the same list.

Going deeper

3 more ideas

The comparator contract

The function must be consistent. If compare(a, b) is negative, compare(b, a) must be positive. If a comes before b and b before c, then a must come before c. And it must give the same answer every time for the same pair. A comparator like () => Math.random() - 0.5 breaks these rules, and the result depends on the engine's algorithm: it is not a fair shuffle.

Why a - b and not a > b

The comparator must return a number. (a, b) => a > b returns true or false, which become 1 or 0. It never returns a negative number, so the sort can't tell "less" from "equal", and engines give inconsistent results. a - b gives a negative, zero, or positive number, exactly as required. Use b - a for descending order.

Stability and multi-key sorting

Because sort is stable, sorting by name and then sorting the result by price gives "by price, ties by name". The last sort is the most important key. This is how a table that lets users click column headers one after another behaves. Doing it in one comparator is usually clearer and faster.

Work it by hand

Every step in a table, with why

Sort 5 products: in stock first, then by price, then by name. These are the exact comparator calls Node.js 22 (V8) made, in order.

products = [{ name: "Lamp", price: 30, inStock: true }, { name: "Desk", price: 120, inStock: false }, { name: "Pen", price: 3, inStock: true }, { name: "Clock", price: 30, inStock: true }, { name: "Mat", price: 3, inStock: false }]

StepCallabDecided byReturnsWhat happensWhy
11DeskLampline 41Desk goes after LampV8 first checks whether the list starts with a sorted run. Only Lamp is in stock, so a.inStock ? -1 : 1 returns 1.
22PenDeskline 4-1The run stops at DeskPen should come before Desk, so the starting run is just Lamp, Desk.
33PenDeskline 4-1Search left of DeskNow V8 inserts Pen with a binary search over the run. The same pair is compared again, because this is a new phase.
44PenLampline 7-27Pen goes to index 0Both are in stock, so the stock check is skipped. 3 - 30 is -27. Order: Pen, Lamp, Desk.
55ClockLampline 9-1Search left of LampSame stock, same price 30, so the names decide: "Clock".localeCompare("Lamp") is negative.
66ClockPenline 727Clock goes to index 130 - 3 is positive, so Clock goes after Pen. Order: Pen, Clock, Lamp, Desk.
77MatLampline 41Search right of LampMat is out of stock and Lamp isn't, so Mat goes after it, whatever the prices.
88MatDeskline 7-117Mat goes before DeskBoth are out of stock, so price decides: 3 - 120. Final order: Pen, Clock, Lamp, Mat, Desk.

It returns Pen, Clock, Lamp, Mat, Desk after 8 comparator calls; comparing every pair would take 10. Another engine may make different calls, but the result is the same, because the comparator is consistent and the sort is stable.

Compared with other ways

When to pick something else
ApproachTimeExtra memoryChoose it when
Sorting with comparatorsO(n log n)O(n)Almost always: one clear rule in one function, passed to the engine's stable sort.
Precomputed keys, then sortO(n log n)O(n)Computing a key is expensive, like parsing dates or lowercasing long strings, so you do it n times instead of n log n times.
Several stable sorts, least important key firstO(k × n log n)O(n)The keys come from user actions, like clicking column headers in turn, and each click adds one more sort.
Counting sort on a small keyO(n + k)O(n + k)The only key is a small whole number, like a priority from 0 to 3, and the list is huge.

Extra memory: O(n): the copy made by [...products] (or toSorted), plus the engine's merge buffer, which can be up to half the list.

How the steps grow

1101001k10k100k1M116324864input size n
Steps as the input grows, for Sorting with comparators and the other approaches, on a scale where each line up is 10 times more steps.
  • Sorting with comparators, Precomputed keys, then sort O(n log n)
  • Several stable sorts, least important key first O(k × n log n)
  • Counting sort on a small key O(n + k)
About how many steps for bigger inputs
Approachn = 10n = 1,000n = 1,000,000
Sorting with comparators339,96620 million
Precomputed keys, then sort339,96620 million
Several stable sorts, least important key first33210 million20 trillion
Counting sort on a small key202,0002 million

Inputs that need care

5 tricky inputs
Numbers with no comparator: [25, 100, 3]
sort() compares them as strings, so "100" comes before "25" and "3". The result is [100, 25, 3]. Always pass (a, b) => a - b for numbers.
undefined items: [3, undefined, 1] with (a, b) => a - b
sort moves undefined to the end without calling the comparator at all, giving [1, 3, undefined]. null is not special: null - 1 is -1, so null sorts like 0.
A comparator that returns NaN, like (a, b) => a.name - b.name
Strings minus strings give NaN, which the spec treats as 0: every pair looks equal, so the list stays in its original order with no error. Use localeCompare for strings.
BigInt values: [3n, 1n, 2n] with (a, b) => a - b
a - b is a BigInt, and sort throws "Cannot convert a BigInt value to a number". Return a < b ? -1 : a > b ? 1 : 0 instead.
Names with mixed case: ["bob", "Ann", "cal"]
Without a comparator, uppercase letters come before all lowercase ones, so "Ann" is first only by luck; "Zed" would beat "amy". localeCompare or Intl.Collator orders them the way people expect.

Variations and follow-ups

Twists interviewers add

Precompute the keys

If a key is expensive, like parsing a date string, it would be recomputed in every one of the O(n log n) calls. Map each item to { key, item } once, sort by key, and map back. This is called decorate, sort, undecorate.

Several stable sorts

Because sort is stable, sorting by name, then by price, then by stock gives "stock first, then price, then name". The last sort is the most important key. Clicking table headers one after another produces exactly this.

Intl.Collator for text

new Intl.Collator("en", { numeric: true }).compare sorts "file2" before "file10" and handles accents and case. Create the collator once and reuse its compare; it is faster than calling localeCompare in every comparison.

Booleans and descending order

b.inStock - a.inStock puts true first, because true - false is 1. For any numeric key, swapping a and b reverses the order: b.price - a.price sorts from most to least expensive.

In an interview

Spot it, explain it, answer follow-ups
  • Comparators show up in almost every sorting question with objects: "sort intervals by start", "sort people by height, then name", "order these logs". Say the comparator out loud as a list of keys in priority order.
  • Know the traps by heart: sort() with no comparator sorts as strings, a boolean result like a > b is not a valid comparator, and sort changes the array it is called on.
  • Test a comparator by hand on three items where two tie on the first key. Check that it returns 0 only when every key is equal, and that compare(a, b) and compare(b, a) have opposite signs.
  • A common follow-up is "what is the complexity?". Answer: O(n log n) comparator calls, so the total is that times the cost of one call. If a call is slow, precompute the keys.

Where frontend code uses it

The same idea in real UI code
  • Sortable table columns in admin dashboards: one comparator per column, with the current column first and a stable tiebreak.
  • Showing file names in natural order (file2 before file10) with Intl.Collator(undefined, { numeric: true }).compare.
  • Ordering notifications by priority ranking, then by time, before rendering them in a list.
  • Avoiding the in-place mutation trap in React and Redux by sorting a copy or using Array.prototype.toSorted.

Related algorithms