A practical guide to writing correct, fast, and readable sorts in JavaScript — with copy-paste utilities you’ll reuse forever.

Introduction
Sorting is one of those “it should be simple” tasks that quietly spawns bugs:
- Numbers accidentally sorted as strings (
[2,10,1] → [1,10,2]
). - UIs re-rendering weirdly because
sort()
mutated shared state. - Inconsistent comparators causing “Heisenbugs” (order changes across runs).
- Diacritics and locales breaking A–Z expectations.
null
/undefined
values jumping around between deploys.
This guide shows how to sort without surprises. We’ll cover the rules the engine follows, safe comparator patterns, immutability, locale-aware string sorts, multi-key sorts, nulls-last, stable sorting, and performance tricks (Schwartzian transform, Intl.Collator
). Keep it handy as your sorting checklist.
1) First Principles: How Array.prototype.sort
works
- Default behavior (no compare function): converts items to strings and compares by UTF-16 code units (lexicographical).
[2, 10, 1].sort(); // ["1","10","2"] → [1, 10, 2] ❌ not numeric
- With a comparator: pass a function
(a, b) => number
returning:
< 0
→a
comes beforeb
> 0
→a
comes afterb
0
→ considered equal (important for stability)
2. In-place: sort()
mutates the array.
3. Stable: modern JS requires a stable sort (items that compare equal keep their relative order).
3. Sparseness: “holes” (missing indices) are treated as undefined
and moved to the end with the default sort.
Rule of thumb: Always provide a comparator unless you truly want lexicographic string ordering.
2) Don’t Mutate Shared State: prefer toSorted
If you’re in React/solid state or any code that relies on immutability, avoid sort()
mutating in place.
- Non-mutating modern method:
const sorted = arr.toSorted((a, b) => a - b); // returns a new array
2. Fallback for older runtimes:
const sorted = [...arr].sort((a, b) => a - b);
Checklist
- UI/state code → use
toSorted
/ copy-then-sort. - Algorithmic / local arrays →
sort()
is fine.
3) Numbers: the classic “gotcha” and the right way
Numeric ascending
const nums = [2, 10, 1];
nums.toSorted((a, b) => a - b); // [1, 2, 10]
Numeric descending
nums.toSorted((a, b) => b - a);
BigInt safe (avoid subtraction)
const bigs = [2n, 10n, 1n];
bigs.toSorted((a, b) => (a < b ? -1 : a > b ? 1 : 0));
Handling NaN (place NaN at the end)
const numeric = (a, b) => {
const an = Number(a), bn = Number(b);
const aNaN = Number.isNaN(an), bNaN = Number.isNaN(bn);
if (aNaN && bNaN) return 0;
if (aNaN) return 1; // NaN last
if (bNaN) return -1;
return an - bn;
};
4) Strings: ASCII vs humans (use Intl.Collator
)
Lexicographic by code units (fast, but not human-friendly):
names.toSorted(); // "Z" < "a", "Å" sorts oddly, accents ignored
Human-friendly collation:
const collator = new Intl.Collator('en', { sensitivity: 'base' });
// base → ignore case/accents for primary ordering
names.toSorted((a, b) => collator.compare(a, b));
Case-insensitive without Intl
(OK for simple English, not full i18n):
names.toSorted((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }));
Tip: If you sort strings often, reuse the
Intl.Collator
instance for speed. Creating it per compare call is slow.
5) Objects: sort by one or multiple keys
By one key
const byKey = key => (a, b) => {
const x = a[key], y = b[key];
return x < y ? -1 : x > y ? 1 : 0;
};
users.toSorted(byKey('age'));
By multiple keys (primary, then tie-break)
const by = (...comparators) => (a, b) => {
for (const cmp of comparators) {
const r = cmp(a, b);
if (r !== 0) return r;
}
return 0;
};
const asc = key => (a, b) => (a[key] < b[key] ? -1 : a[key] > b[key] ? 1 : 0);
const desc = key => (a, b) => (a[key] > b[key] ? -1 : a[key] < b[key] ? 1 : 0);
const collator = new Intl.Collator('en', { sensitivity: 'base' });
const strAsc = key => (a, b) => collator.compare(a[key] ?? '', b[key] ?? '');
users.toSorted(by(
strAsc('lastName'), // A–Z last name
strAsc('firstName'), // then first name
desc('score') // highest score first for ties
));
Nested paths
const byPath = (...path) => (a, b) => {
const va = path.reduce((o,k)=>o?.[k], a);
const vb = path.reduce((o,k)=>o?.[k], b);
return va < vb ? -1 : va > vb ? 1 : 0;
};
orders.toSorted(byPath('customer', 'address', 'city'));
6) Put null
/undefined
at the end (or beginning)
“Nulls last” is a common requirement.
const nullsLast = cmp => (a, b) => {
const aNull = a == null, bNull = b == null;
if (aNull && bNull) return 0;
if (aNull) return 1;
if (bNull) return -1;
return cmp(a, b);
};
// Example: sort by price ascending, nulls last
const byPrice = (a, b) => a.price - b.price;
products.toSorted((a, b) => nullsLast(byPrice)(a, b));
Works the same for key selectors: check
a[key] == null
.
7) Comparator correctness: avoid “random” results
A comparator must be:
- Antisymmetric:
cmp(a,b) = -cmp(b,a)
- Transitive: if
cmp(a,b) < 0
andcmp(b,c) < 0
thencmp(a,c) < 0
- Reflexive to zero:
cmp(a,a) = 0
Bad (inconsistent) comparator
// ❌ randomizes order when scores equal because it never returns 0
const bad = (a, b) => (a.score > b.score ? -1 : 1);
Good comparator
const good = (a, b) => (a.score > b.score ? -1 : a.score < b.score ? 1 : 0);
When equal, return 0. Stability then guarantees original order for ties.
8) Performance: sort once, compute keys once
If your key extraction is expensive (e.g., toLowerCase()
, parsing dates, computing ranks), use the Schwartzian transform (“decorate → sort → undecorate”).
// Decorate
const decorated = items.map(item => ({
item,
key: item.name.toLocaleLowerCase(), // compute once
}));
// Sort by precomputed key
decorated.sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
// Undecorate
const result = decorated.map(d => d.item);
This avoids recomputing the key O(n log n) times.
Other perf notes
- Reuse
Intl.Collator
(don’t new it inside comparator). - Prefer simple comparisons over
localeCompare
if you don’t need i18n. - Sorting is O(n log n); cutting work inside each comparison saves a lot overall.
- For “already almost sorted” data, modern engines are quite efficient (TimSort-like), but correctness still comes first.
9) Dates and times (no more string traps)
By timestamp (fast & correct)
events.toSorted((a, b) => new Date(a.when) - new Date(b.when));
If you already have Date
objects:
events.toSorted((a, b) => a.date - b.date);
Avoid string compare like "2024-2-10" < "2024-12-01"
(string format must be ISO with zero-padding to be safe).
10) Sorting booleans, enums, and custom orders
Booleans (false first, true last)
items.toSorted((a, b) => Number(a.flag) - Number(b.flag));
Custom order (e.g., status lanes)
const order = new Map([['todo', 0], ['doing', 1], ['done', 2]]);
const byStatus = (a, b) => order.get(a.status) - order.get(b.status);
tasks.toSorted(byStatus);
11) Stable multi-pass sorts (when you don’t want a comparator)
Because sort is stable, you can do secondary → primary in two passes:
// sort by firstName, then lastName (primary)
users
.toSorted((a, b) => a.first.localeCompare(b.first))
.toSorted((a, b) => a.last.localeCompare(b.last));
The second sort preserves ties by last
using the prior first
order.
Great for quick scripts, but for production prefer a single combined comparator (less work).
12) Dealing with mixed types
When arrays contain mixed types (numbers, strings, null
, objects), define a type order then apply per-type comparison.
const typeRank = v =>
v == null ? 3 :
typeof v === 'number' ? 0 :
typeof v === 'string' ? 1 :
2; // objects/others
const mixedCompare = (a, b) => {
const ra = typeRank(a), rb = typeRank(b);
if (ra !== rb) return ra - rb;
// same type: compare within type
if (typeof a === 'number') return a - b;
if (typeof a === 'string') return a.localeCompare(b, undefined, { sensitivity: 'base' });
return 0; // or add more rules for objects
};
arr.toSorted(mixedCompare);
13) Practical utilities (copy-paste)
A. Simple comparator builders
export const asc = sel => (a, b) => {
const x = sel(a), y = sel(b);
return x < y ? -1 : x > y ? 1 : 0;
};
export const desc = sel => (a, b) => {
const x = sel(a), y = sel(b);
return x > y ? -1 : x < y ? 1 : 0;
};
export const by = (...cmps) => (a, b) => {
for (const c of cmps) { const r = c(a, b); if (r) return r; }
return 0;
};
B. Nulls-last wrapper
export const nullsLast = cmp => (a, b) => {
const aN = a == null, bN = b == null;
if (aN && bN) return 0;
if (aN) return 1;
if (bN) return -1;
return cmp(a, b);
};
C. Collator-based string comparators
const coll = new Intl.Collator(undefined, { sensitivity: 'base' });
export const strAsc = sel => (a, b) => coll.compare(sel(a) ?? '', sel(b) ?? '');
export const strDesc = sel => (a, b) => coll.compare(sel(b) ?? '', sel(a) ?? '');
D. Key extraction once (Schwartzian)
export function sortByKeyOnce(items, keyFn, cmp = asc(x => x.key)) {
return items
.map(item => ({ item, key: keyFn(item) }))
.sort(cmp)
.map(d => d.item);
}
E. Numeric, guarding NaN
export const numAsc = sel => (a, b) => {
const x = Number(sel(a)), y = Number(sel(b));
const xN = Number.isNaN(x), yN = Number.isNaN(y);
if (xN && yN) return 0;
if (xN) return 1;
if (yN) return -1;
return x - y;
};
14) Testing your sorts (prevent regressions)
- Equal items remain in order (stability):
const arr = [{k:1,i:0},{k:1,i:1},{k:2,i:2}];
const out = arr.toSorted((a,b)=>a.k-b.k);
// expect out.map(x=>x.i) toEqual [0,1,2]
- No mutation when you promise immutability:
const original = [3,1,2];
const out = original.toSorted((a,b)=>a-b);
// expect(original).toEqual([3,1,2])
// expect(out).toEqual([1,2,3])
- Nulls-last behavior:
const out = [null, 1, 0, undefined].toSorted((a,b)=>nullsLast((x,y)=>x-y)(a,b));
// expect(out).toEqual([0,1,null,undefined])
- Locale correctness with accents/case (if you rely on it).
15) Common bugs (and quick fixes)

16) Recipes you’ll actually reuse
Sort users by last, then first (case/diacritics-insensitive)
const coll = new Intl.Collator('en', { sensitivity: 'base' });
const usersSorted = users.toSorted((a, b) =>
by(
(x, y) => coll.compare(x.lastName, y.lastName),
(x, y) => coll.compare(x.firstName, y.firstName)
)(a, b)
);
Sort products by price asc, with “price N/A” at the end
const priceAsc = (a, b) => (a.price ?? Infinity) - (b.price ?? Infinity);
products.toSorted(priceAsc);
Sort tasks by custom status lane, then due date
const order = new Map([['todo',0],['doing',1],['review',2],['done',3]]);
const byStatus = (a, b) => order.get(a.status) - order.get(b.status);
const byDue = (a, b) => new Date(a.due) - new Date(b.due);
tasks.toSorted(by(byStatus, byDue));
Sort large list by case-folded name, key computed once
const result = sortByKeyOnce(people, p => p.name.toLocaleLowerCase());
Conclusion
Sorting in JavaScript is easy to write — and easy to get wrong. The “no bugs” approach boils down to:
- Always provide a comparator unless you truly want lexicographic string order.
- Don’t mutate shared state: prefer
toSorted
(or copy first). - Use the right comparator for the data:
- Numbers →
(a,b)=>a-b
- Strings for humans →
Intl.Collator
- Objects → comparator builders and
by(...)
- Nullish handling →
nullsLast
/nullsFirst
4. Return 0
for equal values to leverage stability.
5. Optimize by computing keys once and reusing Intl.Collator
.
Adopt these patterns and you’ll stop shipping “mystery order” bugs, make your code easier to read, and keep performance solid — even on big lists.
Call to Action
What sorting bug bit your team most recently — mutating sort()
, diacritics ordering, or tie-breaks missing?
💬 Drop the story (and data shape) in the comments and I’ll suggest a tailored comparator.
🔖 Bookmark this as your sorting playbook.
👩💻 Share with a teammate who still sorts numbers without a comparator.
Leave a Reply