Sorting with comparators
Tell JavaScript's sort how to order items with a compare function that returns a negative, zero, or positive number.
O(n log n) comparisonsSpace O(n)19 practice problemsStep-through pictureThe 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.
| In the example | In 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 name | Comparing keys one after another, returning at the first difference |
From the slow way to sorting with comparators
- 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. - 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.
- The function needs to answer three ways, not two:
afirst,bfirst, or "equal for sorting". A number does that: negative, positive, or0. That is the contract ofcompare(a, b). The "equal" answer matters, because a stable sort keeps equal items in their original order. - 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
0or a final tie-breaker. Writing one comparator like that and passing it to the built-insorton a copy of the list is sorting with comparators.
How it works
- Copy the array if the original order must be kept:
[...items].sort(...)oritems.toSorted(...)(ES2023). - Write
compare(a, b). Start with the most important key. If the keys differ, return their difference (for numbers) or-1/1. - If the first key is equal, move on to the next key, and so on.
- For strings meant for people, compare with
a.localeCompare(b)or anIntl.Collator, which know about case and accents. - Return
0when 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.
See every step at onceThe same run, laid out side by side
The code
Point at a note to light up the lines it explains.
function sortProducts(products) {return [...products].sort((a, b) => {if (a.inStock !== b.inStock) {return a.inStock ? -1 : 1;}if (a.price !== b.price) {return a.price - b.price;}return a.name.localeCompare(b.name);});}const prices = [10, 9, 1];prices.sort(); // [1, 10, 9]: compared as stringsprices.sort((a, b) => a - b); // [1, 9, 10]
How fast it is
| Time | O(n log n) comparisons |
|---|---|
| Extra memory | O(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 case
O(n)The list is already in comparator order. V8's TimSort finds one run with
n - 1comparator calls and stops. - Average case
O(n log n)A list in random order takes a bit under
n × log₂ ncomparator calls. Sorting 10,000 random numbers in Node.js took about 120,000 calls. - Worst case
O(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(...)ortoSorted. - Subtracting values that are not numbers, such as
a.name - b.name, which givesNaN. UselocaleComparefor 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
sortis 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.
0 of 19 solved. Solve one to earn the bronze medal, half for silver, and all of them for gold.
Start practicingeasy
medium
- #18Top Searched Queries
- #19Sort Tags by Popularity
- #101Count Pairs Under a Budget
- #218Order Package Versions
- #220Biggest Banner Number
- #221Sort the Task Board
- #527Longest Divisible Chain of Breakpoints
- #574Split Interviewers Between Two Offices
- #631Sort Every Suffix
- #717Rank Command Palette Results
- #737Most Specific Route Wins
- #749Completion Order of Batched Requests
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 }]
| Step | Call | a | b | Decided by | Returns | What happens | Why |
|---|---|---|---|---|---|---|---|
| 1 | 1 | Desk | Lamp | line 4 | 1 | Desk goes after Lamp | V8 first checks whether the list starts with a sorted run. Only Lamp is in stock, so a.inStock ? -1 : 1 returns 1. |
| 2 | 2 | Pen | Desk | line 4 | -1 | The run stops at Desk | Pen should come before Desk, so the starting run is just Lamp, Desk. |
| 3 | 3 | Pen | Desk | line 4 | -1 | Search left of Desk | Now V8 inserts Pen with a binary search over the run. The same pair is compared again, because this is a new phase. |
| 4 | 4 | Pen | Lamp | line 7 | -27 | Pen goes to index 0 | Both are in stock, so the stock check is skipped. 3 - 30 is -27. Order: Pen, Lamp, Desk. |
| 5 | 5 | Clock | Lamp | line 9 | -1 | Search left of Lamp | Same stock, same price 30, so the names decide: "Clock".localeCompare("Lamp") is negative. |
| 6 | 6 | Clock | Pen | line 7 | 27 | Clock goes to index 1 | 30 - 3 is positive, so Clock goes after Pen. Order: Pen, Clock, Lamp, Desk. |
| 7 | 7 | Mat | Lamp | line 4 | 1 | Search right of Lamp | Mat is out of stock and Lamp isn't, so Mat goes after it, whatever the prices. |
| 8 | 8 | Mat | Desk | line 7 | -117 | Mat goes before Desk | Both 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
| Approach | Time | Extra memory | Choose it when |
|---|---|---|---|
| Sorting with comparators | O(n log n) | O(n) | Almost always: one clear rule in one function, passed to the engine's stable sort. |
| Precomputed keys, then sort | O(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 first | O(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 key | O(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
- 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)
| Approach | n = 10 | n = 1,000 | n = 1,000,000 |
|---|---|---|---|
| Sorting with comparators | 33 | 9,966 | 20 million |
| Precomputed keys, then sort | 33 | 9,966 | 20 million |
| Several stable sorts, least important key first | 332 | 10 million | 20 trillion |
| Counting sort on a small key | 20 | 2,000 | 2 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 - bfor numbers.undefineditems:[3, undefined, 1]with(a, b) => a - bsortmovesundefinedto the end without calling the comparator at all, giving[1, 3, undefined].nullis not special:null - 1is-1, sonullsorts like0.- A comparator that returns
NaN, like(a, b) => a.name - b.name - Strings minus strings give
NaN, which the spec treats as0: every pair looks equal, so the list stays in its original order with no error. UselocaleComparefor strings. - BigInt values:
[3n, 1n, 2n]with(a, b) => a - b a - bis a BigInt, andsortthrows "Cannot convert a BigInt value to a number". Returna < b ? -1 : a > b ? 1 : 0instead.- 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".localeCompareorIntl.Collatororders 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 likea > bis not a valid comparator, andsortchanges 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
0only when every key is equal, and thatcompare(a, b)andcompare(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 (
file2beforefile10) withIntl.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.