BitlystBitlyst
PostsSeriesTopicsAbout

Bitlyst

Short, diagram-first posts on JavaScript, React & Next.js. Follow via RSS or the newsletter.

© 2026 Mohsen Fallahnejad

PostsSeriesTopicsLinksAboutRSSGitHubX
Posts/javascript
Beginner5 min read

Part 1 of JavaScript Plot Twists

The Leaderboard That Ranked 9 Points Above 100

Why JavaScript's sort() compares numbers as strings, why a 'lazy' comparator works in Safari but breaks in Chrome, and the one-line fix.

Mohsen Fallahnejad

Mohsen Fallahnejad

Oct 31, 20263 readsDiscuss

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:

js
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:

js
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. 1

    sort() needs to order 100 and 25

    No compare function was passed, so the default rule kicks in

  2. 2

    Both become strings

    “100” and “25” — they aren't numbers any more

  3. 3

    Compare the first character

    Like a dictionary: left to right, one character at a time

  4. 4

    '1' vs '2'

    Character code 49 vs 50. '1' is smaller, so the decision is made right here

then
Result
  1. ↳“100” goes before “25”
  2. →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:

js
[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:

js
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:

js
// 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. 1

    Engine calls compare(a, b)

then it depends
Negative number
  1. ↳a goes first
Zero
  1. ↳keep the current order
Positive number
  1. ↳b goes first
true / false
  1. ↳coerced to 1 / 0
  2. →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:

js
const ranking = players.toSorted((a, b) => b.score - a.score);
// 100, 25, 10, 9, 3 — in every engine
  • 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:

js
["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 - 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.
Filed under#javascript#arrays#sort#gotchas#interview

Series

JavaScript Plot Twists

Real bugs caused by JavaScript doing exactly what the spec says — and nothing you expected.

  1. 1The Leaderboard That Ranked 9 Points Above 100 · you are here
  2. 2You Selected Rows 3, 8, 12, 15 and 20. JavaScript Checked 3, 1 and 8.
  3. 3The Settings Object That Came Back undefined
  4. 4The $4.35 Item That Was Charged $4.34
  5. 5The Leaderboard Arrived Sorted. JavaScript Re-sorted It by User ID.
  6. 6The birthday that showed up a day early · coming soon
  7. 7The forEach that didn't wait for await · coming soon
  8. 8The regex that matched every other time · coming soon
Next part: You Selected Rows 3, 8, 12, 15 and 20. JavaScript Checked 3, 1 and 8. →

Was this worth your time?

One tap tells me what to write more of.

Mohsen Fallahnejad

Written by

Mohsen Fallahnejad

Frontend engineer explaining JavaScript, React & Next.js internals in posts you can finish over coffee. More about me

X ↗GitHub ↗LinkedIn ↗
← OlderI Optimized a Button From O(n) to O(1). The Speed Didn't Matter.Newer →One Line in My Root Layout Turned 158 Static Pages Into 0

Start the discussion

Questions, corrections, a better approach? I read and reply to every comment.

Read next

You Selected Rows 3, 8, 12, 15 and 20. JavaScript Checked 3, 1 and 8.

Why ['1', '7', '11'].map(parseInt) returns [1, NaN, 3]: map passes the index as parseInt's radix. Plus the Number('') trap hiding in the obvious fix.

Nov 1, 20264 min read2

The $4.35 Item That Was Charged $4.34

Why 0.1 + 0.2 !== 0.3 in JavaScript, how floating-point math drops a cent when converting dollars to cents, and why money should be stored as integers.

Nov 3, 20265 min read

The Leaderboard Arrived Sorted. JavaScript Re-sorted It by User ID.

Why JavaScript object key order puts integer-like keys first in ascending order, how that silently reorders JSON from your API, and when to use an array or Map.

Nov 3, 20265 min read

Get new posts in your inbox

No spam. Unsubscribe any time.

5 min left
Discuss👍 React
👍