Picture a tiny leaderboard for a weekly team quiz. Ten silly JavaScript questions, scores out of 100. This week someone finally gets a perfect 100, and the page puts them fourth. First place goes to a score of 9.
The screenshot lands in the team chat with three words:
"I'm in last??"
Here's the code behind the page:
const scores = players.map((p) => p.score); // [9, 100, 25, 10, 3]
const ranking = scores.sort().reverse();
// expected: [100, 25, 10, 9, 3]
// got: [9, 3, 25, 100, 10]
Sort ascending, reverse it, done. What could go wrong with two built-in methods?
The wrong turn: "the API sends strings"
The first suspect is the one every web developer reaches for: the data must be strings. JSON from an API, a form field, a query param. Strings sort letter by letter, so "100" before "9" would make sense.
So you make sure every score is a real number:
const scores = players.map((p) => Number(p.score));
typeof scores[0]; // "number"
scores.sort().reverse(); // [9, 3, 25, 100, 10] ← still wrong
Real numbers. Same broken result.
The data was never the problem. sort() was doing exactly what it's designed to do.
The reveal: sort() turns your numbers into text
When you call sort() without a compare function, the spec says: convert each element to a string, then compare the strings by their character codes. Numbers don't get special treatment, not even an array made entirely of numbers.
So JavaScript doesn't compare 100 and 25. It compares "100" and "25", the way a dictionary would:
How sort() compares 100 and 25
1. sort() needs to order 100 and 25 — No compare function was passed, so the default rule kicks in
2. Both become strings — “100” and “25” — they aren't numbers any more
3. Compare the first character — Like a dictionary: left to right, one character at a time
4. '1' vs '2' — Character code 49 vs 50. '1' is smaller, so the decision is made right here
└─ Result → “100” goes before “25” → the remaining digits are never even looked at
The same rule puts 10 before 9 (“1” < “9”) and -1 before -10 (the shorter string wins a tie).
Run the whole array through that rule and you get exactly that leaderboard:
[9, 100, 25, 10, 3].sort();
// as strings: "10" < "100" < "25" < "3" < "9"
// result: [10, 100, 25, 3, 9]
// .reverse() → [9, 3, 25, 100, 10] ← 9 points takes first place
The .reverse() made it worse. It turned "string order" into "backwards string order," and that put the single-digit scores on top.
Why is this the default? sort() has to work on any array: strings, numbers, objects, mixed. Turning everything into strings is the only comparison that never throws. It's predictable, just not what you'd guess.
The second wrong turn: the comparator that works in one browser
OK, so you pass a compare function. Most of us know that. This one looks right, and in a quick test it works:
players.sort((a, b) => b.score > a.score); // highest first… right?
Test it in Safari: 100, 25, 10, 9, 3. Perfect. Ship it.
Then someone on Chrome reports that the perfect score still isn't first.
Same code, same data, two answers. Run it in both engines and you can see it:
// JavaScriptCore (Safari, Bun) → 100, 25, 10, 9, 3 ✅
// V8 (Chrome, Node 22) → 9, 100, 25, 10, 3 ❌
The reason is the compare function's contract. The engine calls compare(a, b) many times and only cares about the sign of what you return:
What sort() does with your compare function's answer
Engine calls compare(a, b)
├─ Negative number → a goes first
├─ Zero → keep the current order
├─ Positive number → b goes first
└─ true / false → coerced to 1 / 0 → never negative, so 'a first' can never be said
A boolean comparator can only say "b first" (true → 1) or "equal" (false → 0). It can never say "a first." That breaks the contract, and the spec leaves the result of a broken comparator up to the engine. Safari's sorting algorithm happened to survive it on this data. Chrome's didn't.
"Works in my browser" is the most dangerous kind of working. It's exactly how a bug like this slips through a quick test and a code review.
The fix
Return a number. For numbers, subtraction does it in one expression:
const ranking = players.toSorted((a, b) => b.score - a.score);
// 100, 25, 10, 9, 3 — in every engine
-
b.score - a.scoreis negative whenashould come first, positive whenbshould, zero when tied. Highest first. Swap toa - bfor lowest first. -
toSorted()returns a new array. Plainsort()sorts in place, so it would quietly mutateplayers. That's a second bug waiting to happen ifplayersis React state or a prop. (toSorted()is in every modern browser and Node 20+.)
The same default bites with text too, because uppercase letters have smaller character codes than lowercase:
["banana", "apple", "Cherry"].sort();
// ["Cherry", "apple", "banana"] ← capital C first
["banana", "apple", "Cherry"].sort((a, b) => a.localeCompare(b));
// ["apple", "banana", "Cherry"]
For strings people will read, use localeCompare (or Intl.Collator for big lists). It sorts the way humans expect, accents and all.
With that one change, the perfect score sits on top, 75 points clear of second place, in every browser.
The rule
Never call
sort()without a compare function, and never return a boolean from one.
If you remember one thing: sort() sorts text by default. The moment your array holds numbers, dates or objects, you have to tell it how to compare them. And that answer must be a number.
TL;DR
-
sort()with no arguments converts elements to strings and compares character codes, so[10, 9, 1].sort()gives[1, 10, 9]. - Converting your data to numbers doesn't help. The default comparison turns them back into strings.
- A compare function must return a negative, zero or positive number. A boolean comparator gives different results in Safari and Chrome.
- Use
(a, b) => a - bfor numbers anda.localeCompare(b)for human-readable strings. - Prefer
toSorted()oversort()so you don't mutate the original array, especially React state.
Originally published on Bitlyst.
Top comments (0)