JavaScript Array.sort() and Custom Comparators: The Interview Guide

May 29, 20269 min read
dsaalgorithmsinterview-prepleetcode
JavaScript Array.sort() and Custom Comparators: The Interview Guide
TL;DR
  • Default sort coerces to strings: [10, 9, 2].sort() returns [10, 2, 9] because "10" < "2" lexicographically — always pass a comparator for numbers.
  • The comparator's sign is all that matters: negative puts a before b, positive puts b before a; the engine ignores the magnitude.
  • Subtraction is safe for JavaScript integers: (a, b) => a - b stays exact up to 2^53−1 via IEEE 754 — the integer-overflow bug from Java guides does not apply here.
  • NaN breaks subtraction silently: if either element is NaN, the comparator returns NaN and sort order becomes undefined; filter before sorting.
  • Multi-key sorting uses ||: (a, b) => a.age - b.age || a.name.localeCompare(b.name) falls through to the tiebreaker because zero is falsy.
  • Array.sort() mutates in place: clone with [...arr].sort(...) whenever you need the original order preserved.
  • Stable sort is guaranteed since ES2019: Node 18+, Chrome 70+, and all modern runtimes use TimSort, so equal elements keep their original relative order.

You open a LeetCode problem. You sort an array. The output is wrong, and you have no idea why.

Nine times out of ten, you forgot that Array.prototype.sort converts elements to strings before comparing. Not a quirk. The spec. This guide covers how the JavaScript sort comparator actually works, where it burns you, and a quick reference for every pattern you will hit in a coding interview.

The Default JavaScript Sort Will Burn You

[10, 9, 2].sort() // → [10, 2, 9]

"10" comes before "2" lexicographically because "1" < "2". JavaScript sorts by string encoding unless you pass a comparator function. This has been specified behavior since ES1. Not a bug. An intentional design decision. Made by a human. On purpose.

If you call .sort() on a number array without a comparator, your output is wrong. Every time.

You will stare at it for five minutes. You will add console.log statements. You will question your career. Then you will notice the missing (a, b) => a - b and feel the particular shame of having been defeated by two characters.

The Bug vs Feature fountain meme - a leaking pipe becomes a decorative fountain with a "Feature" label

Lexicographic sort on numbers: "It's not a bug, it's ES1 compliant behavior." The fountain was always the plan.

Negative, Zero, Positive: That's the Whole Comparator

The comparator is a function (a, b) => number. The engine calls it for pairs of elements and uses the sign of the result:

  • Negative: a comes before b
  • Zero: preserve their current relative order
  • Positive: b comes before a

The engine does not care whether you return -1, 0, or 1. The only contract is the sign.

[10, 9, 2].sort((a, b) => a - b) // → [2, 9, 10] ascending [10, 9, 2].sort((a, b) => b - a) // → [10, 9, 2] descending

The subtraction shortcut works because the sign of a - b is all the engine needs.

The Subtraction Trick Is Safe Here (Not Everywhere)

(a, b) => a - b works for integers and typical floats. Use it.

const nums = [3, 1, 4, 1, 5, 9, 2, 6]; nums.sort((a, b) => a - b); // → [1, 1, 2, 3, 4, 5, 6, 9]

The subtraction trick is safe for all safe integers in JavaScript. Unlike Java or C++ where a - b can integer-overflow to the wrong sign, JavaScript's number type is IEEE 754 double precision. Subtraction stays exact for every integer up to 2^53 - 1. The integer-overflow comparator bug you see in Java guides does not apply here.

One case that does bite: NaN. If either element is NaN, a - b returns NaN and the comparator produces undefined behavior. The array lands in an unpredictable order. Validate or filter before sorting if your data might contain NaN.

Don't Use Subtraction on Strings

Two approaches work depending on whether locale matters.

Simple ASCII comparison:

['banana', 'apple', 'cherry'].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)); // → ['apple', 'banana', 'cherry']

Locale-aware comparison:

['Banana', 'apple', 'cherry'].sort((a, b) => a.localeCompare(b)); // → ['apple', 'Banana', 'cherry']

localeCompare handles accented characters, case-insensitive sorting, and multilingual strings correctly. For most LeetCode problems the simple < / > version is fine. localeCompare matters when the problem involves real-world names or non-ASCII characters.

The default sort is fine when elements are single characters within the same case. ['b', 'a', 'c'].sort() works. The trap appears with numbers-as-strings or mixed-length strings where lexicographic order diverges from numeric order.

Sorting Objects: Express the Comparison as a Number

Pick the property you want to sort by and return its numeric comparison:

const people = [ { name: 'Charlie', age: 25 }, { name: 'Alice', age: 30 }, { name: 'Bob', age: 25 }, ]; people.sort((a, b) => a.age - b.age); // → Charlie (25), Bob (25), Alice (30)

This pattern shows up constantly in interval and scheduling problems. Sort intervals by start time: (a, b) => a[0] - b[0]. Sort by end time: (a, b) => a[1] - b[1]. The pattern is always the same: express the comparison as a number, return it.

Two Keys? Chain Comparators With ||

The || trick is the cleanest way to sort by multiple criteria. If the primary comparison returns zero, fall through to the tiebreaker:

people.sort((a, b) => a.age - b.age || a.name.localeCompare(b.name)); // → ascending age, then alphabetical name within ties

This works because zero is falsy in JavaScript. A non-zero comparator result short-circuits the || chain. Zero falls through to the next expression. You can chain as many keys as you need.

tasks.sort((a, b) => b.priority - a.priority || // priority descending a.deadline - b.deadline || // deadline ascending on ties a.title.localeCompare(b.title) // alpha on further ties );

A variant that comes up in frequency problems: build a map first, then close over it in the comparator.

const freq = new Map(); for (const n of nums) freq.set(n, (freq.get(n) ?? 0) + 1); [...new Set(nums)] .sort((a, b) => freq.get(b) - freq.get(a)) .slice(0, k);

The comparator can close over any variable in scope. The map does not have to be global.

Sort Is In-Place. Clone Before You Sort.

Array.prototype.sort mutates the original array. In an interview this matters when you need the original order later, when you are sorting a function parameter, or when the test framework checks the input after your function returns.

// Mutates nums in place nums.sort((a, b) => a - b); // Safe: sort a copy const sorted = [...nums].sort((a, b) => a - b); const sorted2 = nums.slice().sort((a, b) => a - b); // equivalent

Reach for [...arr].sort(...) as a default habit. The performance difference versus slice() is irrelevant at interview scale.

Python has sorted() (returns a new list) and .sort() (in-place). JavaScript's .sort() is always in-place. There is no non-mutating variant in the standard library.

You Can Trust Stability Since ES2019

Before ECMAScript 2019 the spec did not require a stable sort. Chrome's V8 used an unstable QuickSort for arrays larger than 10 elements. As of V8 7.0 (Chrome 70, October 2018), V8 switched to TimSort. ES2019 made stability a requirement.

Every JavaScript runtime you will encounter in a LeetCode or interview environment runs a stable sort today. Node 18+, Chrome, Firefox, Safari, and Edge all comply.

Stability only matters when two elements compare as equal and their relative position matters. In multi-pass sorting, sort by the secondary key first, then by the primary key with a stable sort, and the secondary order is preserved within ties. LSD radix sort relies on exactly this property. For most interview problems, just know you can count on it. See the stable sort explainer for when the distinction actually changes your answer.

When .sort() Is the Wrong Tool

If your values are integers in a bounded range, Array.sort() is overkill. Counting sort runs in O(n + k) where k is the value range. For heights from 1 to 100 or scores from 0 to 1000, it is both faster and simpler to implement. Check the counting sort walkthrough for the exact template.

For the general case, .sort() gives you O(n log n) with a TimSort implementation that exploits existing runs. You will almost never need to beat it in a coding interview. The main question is whether a linear-time alternative applies.

Two Ways a Comparator Goes Wrong

First: the comparator returns NaN or undefined. This happens when you accidentally access a missing property.

const bad = (a, b) => a.val - b.notAProperty; // undefined - undefined = NaN // Some engines coerce to 0, wrong but non-crashing

Always check what your comparator returns on a sample pair before running it on the full input. Some engines treat NaN as 0, which means the array looks almost right and no error is thrown. That is the worst possible outcome.

Anime character holding face in shock with text "me realising what the production bug is (I caused it 3 hours ago)"

Three hours of debugging a sorted array. One missing a in (a, b) => a - b.

Second: the comparator violates transitivity. If a < b and b < c but your comparator says a > c, the sort produces undefined results. This cannot happen with numeric subtraction, but it can happen with hand-rolled conditional logic that has a path bug. A sorted output that looks almost right except for a few swapped elements is often a transitivity violation.

A third trap that surfaces less often: sorting arrays that contain null or undefined. Both values fail numeric subtraction silently.

[3, null, 1, undefined].sort((a, b) => a - b) // → behavior is engine-dependent. Do not rely on it.

If your input might have holes, filter them out or handle them explicitly in the comparator. Do not assume null sorts to any particular position.

Interview Patterns Cheat Sheet

GoalComparator
Numbers ascending(a, b) => a - b
Numbers descending(a, b) => b - a
Strings ascending(a, b) => (a < b ? -1 : a > b ? 1 : 0)
Strings locale-aware(a, b) => a.localeCompare(b)
Objects by number field(a, b) => a.field - b.field
Objects by string field(a, b) => a.field.localeCompare(b.field)
Descending frequency(a, b) => freq.get(b) - freq.get(a)
Intervals by start(a, b) => a[0] - b[0]
Multi-key (number, then string)(a, b) => a.x - b.x || a.name.localeCompare(b.name)

If you want to practice explaining these decisions out loud under interview pressure, SpaceComplexity runs voice-based mock interviews where you work through exactly this kind of problem in real time, with rubric-based feedback on your reasoning, not just your final answer.

Further Reading