Intl.Collator: Locale Order Is Not Code-Unit Order
One probe on fixed lists: how a locale comparison differs from a code-unit sort, how numeric, sensitivity and caseFirst change the outcome, and what resolvedOptions reports.
BotBrowser Team
Want the structured docs for Fingerprint?
This article lives in the editorial library. For step-by-step setup, reference material, and ongoing updates, jump into the docs section.
BotBrowser supports replaying an authorized same-origin fixture under a declared profile and keeping a bounded record of what a page sorted and in which locale, but it cannot change the collation data a runtime ships, cannot make two locales agree on one order, and cannot recover the collation a comparison used after the fact. This review runs one probe over ten fixed lists and records how a locale comparison differs from a plain code-unit sort, how numeric, sensitivity, caseFirst and ignorePunctuation each change the outcome, what resolvedOptions() actually reports, what an unsupported locale falls back to, and why passing the collator instead of its compare function throws.
TL;DR
- A code-unit sort puts
Ápplelast, while a locale sort puts it third, so the two orders disagree on the same five words. - The locale changes the order of the same pair: Swedish compares
äafterz, German and English compare it before, so the probe printspair-sv -> z,äandpair-de -> ä,z. numeric: trueordersitem2beforeitem10, while the default treats them as text and putsitem10beforeitem2.sensitivitydecides which differences count:basetreatsa,Aandáas equal,accentseparates the accented form, andcaseseparates the capital.ignorePunctuation: truemakesabanda-bcompare equal, while the default compares them apart.caseFirstonly shows up once case matters, and it reversesAaBbtoaAbB.resolvedOptions()reports the collation the runtime granted, including a collation named in the locale tag, and an unsupported locale falls back toen-USrather than throwing.Array.prototype.sort(collator)throws aTypeError; onlysort(collator.compare)works.
Contents
- What a collator compares that a code-unit sort does not
- How numeric and sensitivity change the result
- Where case, punctuation, equivalence and locale casing enter the order
- Where canonical equivalence and locale casing surprise a sort
- What resolvedOptions reports and what falls back
- Why the collator cannot be passed to sort directly
- Record a collation reading
What a collator compares that a code-unit sort does not
A default Array.prototype.sort() compares UTF-16 code units. A collator compares text the way a language orders it: accents fold into their base letter, case is a secondary difference, and each locale decides where a letter sits. The probe sorts the same five words both ways:
node=v26.9.0
icu=78.3 unicode=17.0
code-unit -> Apple,apple,banana,zebra,Ápple
locale-en -> apple,Apple,Ápple,banana,zebra
pair-sv -> z,ä
pair-de -> ä,z
pair-en -> ä,z
numeric-off -> item1,item10,item2
numeric-on -> item1,item2,item10
sensitivity-base -> a-vs-A=0 a-vs-á=0 a-vs-b=-1
sensitivity-accent -> a-vs-A=0 a-vs-á=-1 a-vs-b=-1
sensitivity-case -> a-vs-A=-1 a-vs-á=0 a-vs-b=-1
sensitivity-variant -> a-vs-A=-1 a-vs-á=-1 a-vs-b=-1
punct-kept -> 1
punct-ignored -> 0
caseFirst-upper -> AaBb
caseFirst-lower -> aAbB
caseFirst-upper-base -> Aa vs aA
resolved -> locale=de-u-co-phonebk numeric=true sensitivity=accent collation=phonebk
fallback-locale -> en-US
passthrough -> sort(collator)=TypeError sort(collator.compare)=ab
tie-check -> sv(ä vs z)=1 de(ä vs z)=-1
The difference from a plain sort is the same one Array sort and comparators draws for the comparator itself. The first two lines are the whole point in miniature. Ápple has a code unit above z, so the code-unit sort pushes it to the end; a language sees Á as an A with an accent, so the locale sort places it with the other a-words. Neither order is wrong, and a page that compares a list across two runtimes without naming the second one has recorded a result that depends on which sort it used.
The second pair is the harder case. ä is not merely an accented a in Swedish; it is its own letter, and it sorts after z. German keeps it near a. The probe prints the two orders side by side, and a page that hard-codes either one as "the" order breaks on the other language. The tie-check line shows the same thing as a comparison result rather than a sorted list: the same pair returns 1 in Swedish and -1 in German.
How numeric and sensitivity change the result
numeric: true switches a run of digits from a text comparison to a numeric one, and the probe shows the difference on three list entries:
numeric-off -> item1,item10,item2
numeric-on -> item1,item2,item10
The default reads item10 as the characters "1", "0", so it comes before item2 as "2". With numeric, the runtime reads the digit run as the number ten, so item2 comes first. A page that sorts file names or table rows by a default collator and expects natural order gets the first line, not the second.
sensitivity decides which differences the comparison reports at all, and the probe compares three pairs under each of the four levels:
sensitivity-base -> a-vs-A=0 a-vs-á=0 a-vs-b=-1
sensitivity-accent -> a-vs-A=0 a-vs-á=-1 a-vs-b=-1
sensitivity-case -> a-vs-A=-1 a-vs-á=0 a-vs-b=-1
sensitivity-variant -> a-vs-A=-1 a-vs-á=-1 a-vs-b=-1
base reports 0 for both the case pair and the accent pair, so a, A and á are all equal and only b sorts apart. accent starts separating the accented form but still treats a and A as equal. case does the opposite: it separates the capital and re-folds the accent. variant separates everything. A lookup table keyed by a lowercased, accent-stripped string is doing by hand what sensitivity: 'base' does in one option, and a table that wants A and a to collide but á to sort separately wants accent.
The distinction matters for a fingerprinting question as much as for a sort. A page that groups strings by sensitivity: 'base' and a page that groups by variant produce different group counts from the same input, so a "number of distinct values" reading has to name the sensitivity or it is not comparable between two pages.
There is a second-order trap in the same table. sensitivity: 'base' reports 0 for a versus á, and a page that treats that 0 as "these strings are the same value" will merge two entries a user considers distinct. Comparisons report an order, not equality: 0 means the collator has no opinion about which comes first, and a Map keyed by the original strings still holds both. A dedupe has to decide separately whether a tie means "keep the first", "keep both", or "merge", and the collator does not answer that question.
The four levels also nest in a fixed order, and knowing the order predicts every row. base ignores accents and case; accent adds the accent distinction; case instead adds the case distinction and keeps the accent folded; variant adds both. That is why accent and case disagree on different pairs rather than one being a stricter version of the other, and why a page that wants "same word, any case, any accent" wants base rather than a chain of manual normalizations.
Where case, punctuation, equivalence and locale casing enter the order
ignorePunctuation makes the collator skip punctuation when it compares, and the probe shows the effect on one pair:
punct-kept -> 1
punct-ignored -> 0
ab and a-b differ when punctuation counts, and are equal when it does not. A page that sorts a list of names with hyphens and expects ab and a-b to stay apart is relying on the default, and a page that dedupes on a collator with punctuation ignored will merge them. The option is a policy choice, not a detail.
The same option changes what a search box considers a match. A filter that lowercases the query and compares with a default collator keeps a-b and ab apart, so typing ab finds nothing in a list that only contains a-b. Turning punctuation off makes the query match, and turning it off also makes a.b and a-b match each other, which is usually what a reader expects from a search field and rarely what an exact-match filter wants. One option, two products, opposite defaults.
caseFirst sets which case comes first once case is a difference at all, and the probe shows both the effect and its limit:
caseFirst-upper -> AaBb
caseFirst-lower -> aAbB
caseFirst-upper-base -> Aa vs aA
With default sensitivity, upper puts the capital before the lowercase in each pair and lower reverses it. The third line is the limit: under sensitivity: 'base' the two cases are equal, so caseFirst has nothing to order, and the printed result is just the input order in each case. An option that has no effect unless another option is set is easy to misread as broken, and the probe is the cheapest way to see that it is not.
That dependency is worth stating as a rule: caseFirst is a tiebreaker inside a comparison that already distinguishes case, and it does nothing above that level. A page that sets caseFirst: 'upper' and sensitivity: 'base' and expects capitals first has set two options that cancel, and the fix is to drop the sensitivity override rather than to add a manual comparison. The probe's last line shows both input orders surviving untouched, which is the visible form of the cancellation.
Where canonical equivalence and locale casing surprise a sort
Two of the most confusing behaviours are not about the option flags at all; they come from how the collator treats text that looks the same and text whose case depends on a language. A second probe records both.
The first is canonical equivalence. The letter é can be stored as one code point or as e followed by a combining accent, and the two strings are not equal:
normalization-text -> nfc.length=1 nfd.length=2 equal=false
normalization-collate -> base=0 variant=0
The lengths differ and a string comparison says they are different, yet the collator reports 0 at both base and even variant sensitivity, because the two spellings are canonically equivalent and the collation data folds them together. A page that compares with a collator therefore treats two byte-different strings as the same word, while a page that compares with === or a Map does not. That split is the single most common source of "it sorts right but the lookup misses": the sort used the collator, the lookup used the raw string.
The first is also the reason a locale comparison and a text comparison disagree, which Unicode normalization covers from the string side. The second is locale casing. In Turkish, I lowercases to a dotless ı, and i upper-cases to a dotted İ, which is a different letter from the English I:
dotted-i-tr -> i-vs-I=1 i-vs-İ=-1
dotted-i-en -> i-vs-I=-1 i-vs-İ=-1
turkish-lower -> tr=ı en=i
Under Turkish the collator places i after I and before İ, so i and I are no longer the same letter in either case or order; under English it places i before both. The last line shows the same split at the string level: 'I'.toLocaleLowerCase('tr') is ı while the English call returns i. A page that lowercases with no locale and then compares gets the English order in both locales, which is why a locale-aware comparison and a locale-aware case fold have to be chosen together rather than one at a time.
There is a third shape worth recording because it breaks the assumption that a code-unit sort is "close enough". Astral characters, which occupy two code units, sort by their first code unit under a plain sort and by their collation weight under a collator:
astral-codeunit -> 61,7a,1f600
astral-collator -> 1f600,61,7a
The plain sort puts the emoji last because its leading unit is above z; the collator puts it first because its collation weight comes before letters. A page that mixes astral characters into a list of names and sorts by default gets an order no reader expects, and the fix is the collator, not a manual reordering.
Finally, the prologue to all of this is that localeCompare and a collator agree, which is the cheapest way to confirm a hypothesis before building a collator:
localeCompare-vs-collator -> ä-z:-1/-1 a-b:-1/-1
hoisted-vs-per-call -> same=true order=Alpha,bravo,charlie,delta
The first line shows 'ä'.localeCompare('z', 'de') and a German collator returning the same -1, so a probe can start with the one-liner and only reach for a collator when it needs options. The second line shows a hoisted collator and one constructed per comparison producing the same order, which confirms the earlier claim that hoisting changes cost rather than result.
What resolvedOptions reports and what falls back
resolvedOptions() returns what the runtime actually granted, after the locale tag and the options are both applied:
resolved -> locale=de-u-co-phonebk numeric=true sensitivity=accent collation=phonebk
The collator was asked for de-u-co-phonebk with two options, and the resolved locale keeps the -u-co-phonebk extension and reports collation=phonebk separately. That split matters because a page can read either field and get a different string, and a record that stores only locale and drops collation has lost the part that decided the order. The phonebook collation orders ä with ae in German, which is a third order again, different from both the plain German and the Swedish one the earlier section printed.
An unsupported locale does not throw. The probe asks for zz-ZZ:
fallback-locale -> en-US
The runtime fell back to en-US and said so. A page that reads the locale from a header or a query parameter and passes it straight into a collator therefore always gets a collator, and only resolvedOptions().locale reveals that the request was not honoured. A record that stores the requested locale instead of the resolved one has recorded an intention, not a measurement.
Why the collator cannot be passed to sort directly
A collator is an object with a compare method, and Array.prototype.sort() accepts a function, not an object with a compatible method. The probe passes the collator itself:
passthrough -> sort(collator)=TypeError sort(collator.compare)=ab
The first half throws a TypeError because the argument is not callable; the second half sorts correctly because collator.compare is a bound function. That is a one-word bug in a code review and a runtime exception in production, and it is worth naming because the message from sort does not mention collators at all.
The same shape explains a subtler mistake. A collator created once and reused is more than a factory for compare; it holds the locale and the options, and its compare is bound to them. A page that creates a new collator per comparison inside a sort callback rebuilds the ICU state thousands of times, which is correct and slow, and a page that hoists one collator out of the loop gets the same order faster. The probe cannot show the timing difference on a five-element list, and the ordering is identical either way, so the choice is purely about cost.
Record a collation reading
A collation reading supports a narrow claim: this runtime, this ICU version, this locale and these options, produced these orders. Five fields keep it checkable:
- The runtime and its ICU version, because the probe prints
icu=78.3 unicode=17.0and a different ICU ships different collation data. - The requested locale and the resolved locale, because an unsupported tag falls back silently.
- The resolved collation, because a
-u-co-extension changes the order and is reported separately from the locale. - The four options that change the outcome,
numeric,sensitivity,caseFirstandignorePunctuation, because two collators with the same locale and different options disagree. - The comparison used,
sort()orcollator.compare, because passing the object throws.
Public sources
- MDN: Intl.Collator
- MDN: Intl.Collator.prototype.compare
- MDN: Intl.Collator.prototype.resolvedOptions
- ECMA-402: Collator Objects
- Unicode TR35: Collation
- BotBrowser: automation consistency reference
Practical conclusion
Name the collator whenever an order leaves a page: the locale, the resolved collation and the four options, because each one of them changes the result on the same input. Prefer sensitivity to hand-built case and accent folding, and treat ignorePunctuation as a policy decision rather than a detail. Pass collator.compare to sort, never the collator, and hoist one collator out of the loop. For the numeric comparison a default sort gets wrong, read Array sort and comparators; for the locale data behind the order, read Intl API locale data.
This article is part of the guide: What Is a Browser Fingerprint?
Related Articles
Unicode Normalization: Four Text Identities a Page Reads
FingerprintArray Sort and Comparators: A Function You Can Get Wrong
FingerprintICU Timezone Aliases and the TZ You Actually Report
FingerprintObject Key Order: Integer Keys Sort, String Keys Stay
FingerprintPromise Combinators: What Fails First
FingerprintURL Parsing and Search Params: What Round Trips Change
Take BotBrowser from research to production
The guides cover the model first, then move into cross-platform validation, isolated contexts, and scale-ready browser deployment.