JavaScript [10, 9, 1].sort() returns [1, 10, 9]: the default sort comparator
Our pricing page listed plans as 100, 1000, 25, 250. Array.prototype.sort() without a comparator converts values to strings and compares UTF-16 code units. We fixed every call site, handled nulls and mixed strings, and added a lint rule so it cannot return.
A customer success manager sent a screenshot: the plan picker on our pricing page showed seat tiers in the order 100, 1000, 25, 250, 50, 500. It had been correct the week before. Sales teams were sharing the page with prospects, so it was embarrassing rather than broken, but it also meant the "recommended" badge, which was attached to the second item, sat on the wrong tier.
The data came from our pricing API already in the right order. The front-end code sorted it again after merging in regional overrides:
const tiers = mergeOverrides(apiTiers, regionOverrides)
.map((t) => t.seats)
.sort();
Reproducing it
Any browser console or Node REPL shows it:
[10, 9, 1].sort(); // [1, 10, 9]
[100, 25, 1000, 50].sort(); // [100, 1000, 25, 50]
What we ruled out
- The API returning strings. We checked the JSON;
seatswas a number. - The override merge reordering items. It did reorder them, which was why someone added
.sort()in the first place. The sort was meant as the fix. - A browser difference. Chrome, Firefox and Safari all produced the same wrong order, which pointed to defined behaviour rather than an engine quirk.
Root cause
When Array.prototype.sort is called without a comparator, the specification converts each element to a string and compares the strings by UTF-16 code units. "100" comes before "25" because the character 1 sorts before 2, regardless of the numbers they represent. The previous week's data had only two-digit seat counts, so string order and numeric order happened to agree. Adding the 1000-seat tier exposed it.
What is happening under the hood
The ECMAScript spec's SortCompare step, when no comparator is given:
- Sorts
undefinedvalues to the end, without comparing them. - Converts both values with
ToString. - Compares the resulting strings code unit by code unit.
That makes sense for the general case, because an array can hold anything and every value has a string form. It is a surprise for numbers, because ToString(9) is "9", which is greater than "10".
Related traps we found while auditing the codebase:
| Code | Result | Why |
|---|---|---|
[10, 9, 1].sort() |
[1, 10, 9] |
string comparison |
[-1, -2, 3].sort() |
[-1, -2, 3] |
"-1" < "-2" as strings |
["b", "B", "a"].sort() |
["B", "a", "b"] |
uppercase code units are smaller |
["v1.10", "v1.9"].sort() |
["v1.10", "v1.9"] |
no natural number ordering |
arr.sort((a, b) => a > b) |
unreliable | comparator must return a number, not a boolean |
[3, NaN, 1].sort((a, b) => a - b) |
order not guaranteed | NaN makes the comparator inconsistent |
The boolean comparator deserves a note. Returning true/false gives 1/0, never a negative number, so the algorithm cannot tell "less than" from "equal". Since V8 7.0 (Chrome 70, Node 11) switched to TimSort, such comparators silently produce wrong orders for many inputs. Older code that "worked" with a different algorithm broke this way years ago.
sort also mutates the array in place and returns the same array. Our code sorted a fresh array from map, so that was harmless here, but we found two places that sorted props arrays in React components, mutating state owned by a parent.
Environment
| Component | Version / value |
|---|---|
| OS | macOS 15 (dev), Ubuntu 24.04 (CI) |
| Runtime | Node.js 22.12.0 (build, tests) |
| Browsers | Chrome 131, Firefox 133, Safari 18.2 |
| Language | TypeScript 5.7, strict: true |
| Framework | React 18.3, Vite 6 |
| Lint | ESLint 9 flat config, typescript-eslint 8 |
TypeScript did not complain: number[] has a sort() method with an optional comparator, and the types are correct. This is a semantics bug, not a type error.
Minimal reproduction
const seats = [100, 25, 1000, 50, 250, 500];
console.log([...seats].sort());
// [ 100, 1000, 25, 250, 50, 500 ]
console.log([...seats].sort((a, b) => a - b));
// [ 25, 50, 100, 250, 500, 1000 ]
Who is affected
Every JavaScript engine, by design. Any code that calls .sort() or .toSorted() without a comparator on numbers, dates, mixed-case strings, or strings with embedded numbers. Typed arrays (Int32Array, Float64Array) are the exception: their sort() compares numerically by default.
The fix
1. Pass a numeric comparator. The comparator returns a negative number, zero or a positive number:
const tiers = mergeOverrides(apiTiers, regionOverrides)
.map((t) => t.seats)
- .sort();
+ .sort((a, b) => a - b);
a - b is correct for finite numbers. For values that may exceed safe integer precision, or for bigint, compare explicitly:
const byBigInt = (a: bigint, b: bigint) => (a < b ? -1 : a > b ? 1 : 0);
2. Sort objects by a key, not by mapped values. The real fix kept the tier objects together so the badge stayed attached to the right tier:
const tiers = mergeOverrides(apiTiers, regionOverrides).toSorted(
(a, b) => a.seats - b.seats,
);
toSorted (ES2023, available in Node 20 and all current browsers) returns a new array and leaves the original alone, which also fixed the React prop mutations.
3. Natural ordering for strings with numbers. Plan codes like team-5, team-10, team-50 needed human ordering. Intl.Collator with numeric: true handles that, and creating one collator up front is much faster than calling localeCompare with options for each comparison:
const collator = new Intl.Collator("en", { numeric: true, sensitivity: "base" });
codes.sort(collator.compare);
// ["team-5", "team-10", "team-50"]
4. Make missing values explicit. Overrides could have seats: null for "contact sales". We placed those last on purpose:
const bySeatsNullsLast = (a: Tier, b: Tier) => {
if (a.seats == null) return b.seats == null ? 0 : 1;
if (b.seats == null) return -1;
return a.seats - b.seats;
};
Alternatives and trade-offs
- Sort on the server only. The API already returned the right order; we could have dropped the client sort. We kept a client sort because overrides are merged in the browser, but this is a fine choice if the client never reorders.
- A utility library such as lodash
sortBy. It sorts by key and handles mixed types predictably. Adding a dependency for one comparator was not worth it. - Typed arrays for numeric data.
Float64Array.from(values).sort()sorts numerically with no comparator. Useful in hot numeric paths, awkward for objects.
How to test the fix
Vitest tests with data chosen to break string ordering:
import { describe, expect, it } from "vitest";
import { orderTiers } from "./pricing";
describe("orderTiers", () => {
it("orders seats numerically, not as strings", () => {
const input = [100, 25, 1000, 50, 250, 500].map((seats) => ({ seats }));
expect(orderTiers(input).map((t) => t.seats)).toEqual([25, 50, 100, 250, 500, 1000]);
});
it("puts contact-sales tiers last", () => {
const input = [{ seats: null }, { seats: 10 }, { seats: 5 }];
expect(orderTiers(input).map((t) => t.seats)).toEqual([5, 10, null]);
});
it("does not mutate its input", () => {
const input = [{ seats: 2 }, { seats: 1 }];
orderTiers(input);
expect(input[0].seats).toBe(2);
});
});
npx vitest run src/pricing
Confirming in production
After the deploy we loaded the pricing page in every supported region (overrides differ per region) and read the rendered order from the DOM:
[...document.querySelectorAll("[data-tier-seats]")].map((el) => Number(el.dataset.tierSeats));
// [25, 50, 100, 250, 500, 1000]
The "recommended" badge sat on the 50-seat tier again, and the conversion dashboard for the pricing page returned to its usual click distribution within a day.
Regression guard
typescript-eslint has a rule for exactly this. In eslint.config.js:
import tseslint from "typescript-eslint";
export default tseslint.config(
...tseslint.configs.recommendedTypeChecked,
{
languageOptions: { parserOptions: { projectService: true } },
rules: {
"@typescript-eslint/require-array-sort-compare": ["error", { ignoreStringArrays: true }],
},
},
);
It needs type information, which is why projectService is enabled. ignoreStringArrays: true allows a bare .sort() on string[], where default ordering is sometimes what you want. The rule flagged eleven other call sites; three were real bugs on numeric data.
Security implications
None beyond correctness. A wrong order can still matter for security-adjacent logic, for example picking "the latest" API version or certificate by sorting version strings. We checked for those patterns and found one: a script that chose the newest database migration by sorting file names, which worked only because names were zero-padded. We left it with a comment and a test.
Prevention checklist
- Never call
sort()ortoSorted()without a comparator on non-string data. - Use
a - bfor numbers, explicit comparisons forbigint, andIntl.Collatorwithnumeric: truefor human ordering. - Prefer
toSorted()when the array is shared or comes from props or state. - Decide where
nullandNaNgo and test it. - Enable
@typescript-eslint/require-array-sort-compare.
References
- MDN, Array.prototype.sort(): https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort
- MDN, Array.prototype.toSorted(): https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/toSorted
- ECMAScript specification, Array.prototype.sort: https://tc39.es/ecma262/#sec-array.prototype.sort
- MDN, Intl.Collator: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Collator
- typescript-eslint, require-array-sort-compare: https://typescript-eslint.io/rules/require-array-sort-compare/
- V8 blog, getting things sorted in V8: https://v8.dev/blog/array-sort
Add a label
Embed this post
<iframe src="https://www.hibug.me/javascript-array-sort-numbers-as-strings" width="700" height="420" frameborder="0" loading="lazy"></iframe>
I am a Senior Software Engineer with over a decade of hands-on experience building, scaling, and maintaining complex systems across the full stack—spanning frontend, backend, and DevOps. Over the last 10 years, I have navigated diverse architectures and untangled some of the most frustrating, elusive bugs in production. I believe that every production incident is a massive learning opportunity. Having discovered hibug.me, I am excited to contribute to this knowledge base, breaking down root causes, and sharing verified fixes to help fellow developers debug faster and build more resilient systems.
Comments
0No comments yet. Be the first to comment!
Sign In to post a comment.