The message in our team chat was a screenshot and three words:
"I'm in last??"
It was Friday quiz day. Every week we answer ten silly JavaScript questions, and I'd built a tiny leaderboard page for it. Lena had scored 100, the best result we'd ever had. My page put her fourth. First place went to Arash, with 9.
Here's the code I'd shipped that morning:
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"#
My first thought was the one every web developer has: 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 I spent twenty minutes making sure every score was 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
- “100” goes before “25”
- the remaining digits are never even looked at
Run the whole array through that rule and you get my 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] ← Arash wins with 9
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 worked on my machine#
OK, so you pass a compare function. I knew that. I wrote this, tested it, and it worked:
players.sort((a, b) => b.score > a.score); // highest first… right?
I tested in Safari. 100, 25, 10, 9, 3. Perfect. I pushed it.
Ten minutes later, from someone using Chrome: "Lena's still not first."
Same code, same data, two answers. I ran it in both engines to be sure:
// 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
- 1
Engine calls compare(a, b)
- a goes first
- keep the current order
- b goes first
- 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 this bug got past me.
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.
I fixed the leaderboard and posted the corrected ranking. Lena took first, 75 points clear of second place, and she brings up "the incident" every Friday. 🏆
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.
Start the discussion
Questions, corrections, a better approach? I read and reply to every comment.