Web Development Bug Report

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.

John Nguyen John Nguyen @devbugs · December 18, 2024 · 8 min read · 36 Views
Banner: a list of numbers sorted in dictionary order instead of numeric order

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; seats was 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:

  1. Sorts undefined values to the end, without comparing them.
  2. Converts both values with ToString.
  3. 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() or toSorted() without a comparator on non-string data.
  • Use a - b for numbers, explicit comparisons for bigint, and Intl.Collator with numeric: true for human ordering.
  • Prefer toSorted() when the array is shared or comes from props or state.
  • Decide where null and NaN go and test it.
  • Enable @typescript-eslint/require-array-sort-compare.

References

Add a label

#typescript #javascript #nodejs #eslint #sorting
Written by
John Nguyen @devbugs

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.

Advertisement

Comments

0

No comments yet. Be the first to comment!

Sign In to post a comment.