DEV Community

Mohsen Fallahnejad
Mohsen Fallahnejad

Posted on Edited on Originally published at bitlyst.blog

The Leaderboard That Ranked 9 Points Above 100

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]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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?
Enter fullscreen mode Exit fullscreen mode

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   ❌
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
  • b.score - a.score is negative when a should come first, positive when b should, zero when tied. Highest first. Swap to a - b for lowest first.
  • toSorted() returns a new array. Plain sort() sorts in place, so it would quietly mutate players. That's a second bug waiting to happen if players is 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"]
Enter fullscreen mode Exit fullscreen mode

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 - b for numbers and a.localeCompare(b) for human-readable strings.
  • Prefer toSorted() over sort() so you don't mutate the original array, especially React state.

Originally published on Bitlyst.

Top comments (0)