sort() puts Ángel after zorro, and localeCompare() misplaces ñ
published
TL;DR
words.sort() orders strings by UTF-16 code unit, so 'Zoe' comes before 'ana', and 'Ángel', 'Óscar' and 'ñandú' all land after 'zorro'. Switching to a.localeCompare(b) fixes the accents and the case, but with no locale argument it uses whatever locale the runtime defaults to, and in English collation ñ is just an n with a mark, so 'ñandú' sorts before 'nube'. For Spanish text, sort with new Intl.Collator('es').compare, and name the locale everywhere the order matters.
The problem
One list of Spanish words, three common ways to sort it, three different answers. Run on Node 26.2.0 with ICU 78.3 and a default locale of en-US:
const words = ['zorro', 'Ángel', 'ana', 'Zoe', 'ñandú', 'nube', 'Óscar', 'oso'];
words.toSorted();
// ['Zoe', 'ana', 'nube', 'oso', 'zorro', 'Ángel', 'Óscar', 'ñandú']
words.toSorted((a, b) => a.localeCompare(b));
// ['ana', 'Ángel', 'ñandú', 'nube', 'Óscar', 'oso', 'Zoe', 'zorro']
words.toSorted(new Intl.Collator('es').compare);
// ['ana', 'Ángel', 'nube', 'ñandú', 'Óscar', 'oso', 'Zoe', 'zorro']
The first is obviously broken. The second looks right, and that is the dangerous one: it passes every test with English names and only goes wrong on the ñ. Only the third matches a Spanish dictionary, where ñ is its own letter between n and o.
Why it happens
The default sort compares code units, not letters
Without a compare function, sort() converts every element to a string and compares the strings by UTF-16 code unit. MDN gives the numeric version of the same trap: "80" sorts before "9". For letters, the code points decide everything:
| Character | Code point | Where sort() puts it |
|---|---|---|
Z | U+005A | before every lowercase letter |
a | U+0061 | after every uppercase ASCII letter |
z | U+007A | last of the ASCII letters |
Á | U+00C1 | after z |
ñ | U+00F1 | after z |
So uppercase sorts before lowercase, and every accented Latin letter, uppercase or not, sorts after the entire unaccented alphabet. Nothing about this is locale-aware.
localeCompare uses a real collation, in someone else’s language
localeCompare() and Intl.Collator use language-aware collation (the Unicode Collation Algorithm, implemented by ICU). Accents and case become lower-priority differences, so 'Ángel' sits next to 'ana' and 'Zoe' next to 'zorro'. That part fixes the first result.
But collation rules are per language, and the two languages disagree on ñ. You can see it by asking each collator whether n and ñ are the same base letter:
new Intl.Collator('en', { sensitivity: 'base' }).compare('n', 'ñ'); // 0 — same letter, different mark
new Intl.Collator('es', { sensitivity: 'base' }).compare('n', 'ñ'); // -1 — different letters
English treats ñandú like nandu, and nandu comes before nube. Spanish treats ñ as a letter after n, so every ñ word follows every n word.
When you call localeCompare(b) without a locale, or new Intl.Collator() without one, MDN is explicit about what you get: the runtime’s default locale. On a server that is often en-US. In a browser it is the user’s language. The same function can therefore produce one order during server rendering and a different one in a Spanish-speaking visitor’s browser.
| Approach | Accented letters | Case | ñ | Depends on the runtime’s locale |
|---|---|---|---|---|
sort() | after z | uppercase first | after z | no |
localeCompare(), no locale | next to the base letter | lowercase first | depends: inside n for English | yes |
Intl.Collator('es').compare | next to the base letter | lowercase first | after n | no |
What to do
Create one collator with an explicit locale and reuse it
const collator = new Intl.Collator('es');
names.sort(collator.compare);
compare is bound to its collator, so it can be passed straight to sort(). a.localeCompare(b, 'es') gives the same order, but MDN recommends the collator for big lists: when comparing large numbers of strings, create an Intl.Collator once and use its compare().
'es' and 'es-MX' produced the same order for this list. If you need the traditional Spanish order, where ch and ll sort as single letters, ICU exposes it as a named collation:
['chico', 'cuna', 'llama', 'luz'].sort(new Intl.Collator('es').compare);
// ['chico', 'cuna', 'llama', 'luz']
['chico', 'cuna', 'llama', 'luz'].sort(new Intl.Collator('es-u-co-trad').compare);
// ['cuna', 'chico', 'luz', 'llama']
Options that change real results
const files = ['archivo10.txt', 'archivo2.txt', 'archivo1.txt'];
files.sort();
// ['archivo1.txt', 'archivo10.txt', 'archivo2.txt']
files.sort(new Intl.Collator('es', { numeric: true }).compare);
// ['archivo1.txt', 'archivo2.txt', 'archivo10.txt']
['ana', 'Ana', 'ANA'].sort(new Intl.Collator('es', { caseFirst: 'upper' }).compare);
// ['ANA', 'Ana', 'ana']
new Intl.Collator('es', { ignorePunctuation: true }).compare('o-so', 'oso'); // 0
Search is where the Spanish rule bites back
sensitivity: 'base' is the usual setting for accent- and case-insensitive matching, and in Spanish it does fold á into a and A into a. It does not fold ñ into n, because in Spanish they are different letters:
new Intl.Collator('es', { usage: 'search', sensitivity: 'base' }).compare('nino', 'niño'); // -1
new Intl.Collator('en', { usage: 'search', sensitivity: 'base' }).compare('nino', 'niño'); // 0
That is linguistically correct (cana and caña are different words), but if your search box should find niño when someone types nino on a keyboard without ñ, use a separate folding step for the match and keep the Spanish collator for the display order:
const fold = (s) => s.normalize('NFD').replace(/\p{M}/gu, '').toLowerCase();
fold('Niño'); // 'nino'
fold('ÁRBOL'); // 'arbol'
fold('pingüino'); // 'pinguino'
NFD splits each accented letter into its base letter plus combining marks, and \p{M} removes the marks.
Caveats
- Collation needs ICU locale data. Node’s internationalization docs say full ICU is the default build mode and that the official binaries are built that way, so
'es'works out of the box there; a customsmall-icuor--with-intl=nonebuild will not behave the same. - Collation tables come from ICU and CLDR versions, which change over time. The outputs above are from Node 26.2.0 with ICU 78.3; edge cases can move between versions, so do not hard-code expected orders for unusual characters in tests that run on several runtimes.
- A collator order is for people, not for keys. Do not use it to decide equality for deduplication or as the order a binary search expects unless the data was sorted with the same collator.
- If the list is sorted by a database before it reaches JavaScript, the database’s collation decides that order. Re-sorting on the client with a different locale is how a paginated list ends up with items that seem to jump between pages.