Back to Knowledge Hub
Fingerprint

Array Sort and Comparators: A Function You Can Get Wrong

Sort fixed arrays: the default string order, in-place mutation, a stable sort, where undefined and NaN land, how a bad comparator yields a wrong order, and subtraction overflow.

BotBrowser Team

Documentation

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 bounded observations about the order a page sorted an array into, but it cannot change how a runtime implements the sort algorithm, cannot make a comparator consistent when it is not, and cannot turn a sort result into an identity signal. This review sorts fixed arrays with the default comparator and with explicit comparators, then records why the default order is string order, why the sort mutates in place, how a stable sort keeps equal keys in insertion order, where undefined and NaN land, why a non-consistent comparator yields a wrong but silent order, and why a subtraction comparator overflows on large numbers.

TL;DR

  • The default sort converts every element to a string and orders by UTF-16 code units, so [10,9,1,100].sort() gives [1,10,100,9], not numeric order.
  • sort mutates the array in place and returns the same reference, so a caller who expects a copy silently changes the source.
  • A numeric comparator (a, b) => a - b gives [1,9,10,100], the order a reader expects, but a subtraction comparator overflows when the gap exceeds the double-precision range.
  • The sort is stable, so rows that compare equal keep their insertion order, which is bdac for two keys split 2,1,2,1.
  • undefined and holes sort to the end; a comparator that returns NaN is treated as 0 for that pair, so the element keeps its relative position.
  • A comparator that is not consistent (not transitive) can produce a wrong order without throwing, because the algorithm assumes the comparator is a total order.
  • toSorted returns a copy and leaves the source unchanged, which is the way to sort without mutating.

Contents

One array of 10, 9, 1, 100 enters the default sort and exits as 1, 10, 100, 9 because elements become strings, while a numeric comparator column exits as 1, 9, 10, 100; a STABLE panel keeps two equal-key groups in insertion order, and a NOTE row marks undefined sorting to the end and a subtraction comparator overflowing past the 2 to the 53 range

Why the default sort is string order

Array.prototype.sort with no comparator does not sort numbers as numbers. It converts each element to a string and orders the strings by UTF-16 code unit. The probe sorts the same four numbers both ways:

Array.sort=function node=v26.9.0
[10,9,1,100].sort() -> [1,10,100,9]
[10,9,1,100].sort((a,b)=>a-b) -> [1,9,10,100]

The default sort returns 1, 10, 100, 9, because as strings "1" sorts before "10" sorts before "100" sorts before "9" by code unit: the first character of each decides, and "9" is greater than "1" so the four numbers ending in something other than 9 come first. The comparator form returns 1, 9, 10, 100, the numeric order a reader expects. The two calls sort the same array and disagree completely, and only one of them matches the intuition of "sorted".

That gap is the most common sort defect: a developer calls sort() on an array of numbers, sees a plausible-looking result, and never notices the order is lexicographic. It looks right for single digits and single-digit strings, and breaks the moment a two-digit value appears. The fix is always to pass a comparator for numbers, never to rely on the default, because the default is defined for strings and only happens to look correct for a small input.

The string order also explains why the default sort treats mixed types by their string form. The probe sorts a mixed array of a boolean, a number and a string together, and the order follows each element's String() form rather than any type-aware rule. A caller who assumes the default sort is "natural order" gets code-unit order, which for ASCII letters puts uppercase before lowercase and for accented letters places them after every ASCII letter.

The probe makes the code-unit order explicit with a mixed string and boolean input:

["b","a","C","á"].sort() -> ["C","a","b","á"]
[true,false,2,1].sort() -> [1,2,false,true]

The four strings sort to "C", "a", "b", "á", because uppercase C has a lower code unit than lowercase a, and the accented á has a higher code unit than any plain ASCII letter, so it lands last. A reader who expects alphabetical order would put "á" next to "a" and might not expect "C" before "a", but the default sort is not alphabetical and never claims to be. The mixed boolean and number array sorts to 1, 2, false, true, because "1" and "2" as strings precede "false" and "true", whose first letters f and t have higher code units than the digits.

That behaviour is why a sort over labels that come from user input cannot use the default. Two labels that differ only in case sort in a case-sensitive order that a reader does not expect, and a label that begins with a digit sorts before every label that begins with a letter. The locale-aware alternatives, String.prototype.localeCompare and Intl.Collator, sort by language rules rather than code units, so a list a reader perceives as alphabetical needs one of those rather than the default. A record that stores a default-sorted list of labels has stored code-unit order, which is reproducible but not the order a reader would call sorted.

The cost of getting this wrong is a list that is stable and reproducible yet reads as unsorted to every person who looks at it. A support dashboard that sorts account names with the default comparator puts every uppercase-initial account before every lowercase-initial one, and puts accented names after the whole ASCII block, which is not the order the operator scanning the list expects. The order never throws, never changes between runs on the same engine, and quietly mismatches the intent, which is the worst combination because nothing in a test that only checks "is it sorted" would catch it unless that test encodes the same wrong rule. A test written against the default comparator passes; a test written against the reader's expectation fails, and the two disagree on every list that contains mixed case or an accented name.

There is also an arithmetic reason the default order surprises on numbers that are close in value. Because the default compares the string forms character by character, "2" and "20" compare on their first character and tie there before the second character decides, so a list of ids 2, 20, 3 sorts to 2, 20, 3 as strings but a reader expects 2, 3, 20 as numbers. The longer the shared prefix, the later the decision and the more the text order diverges from the numeric order, which is why the defect grows more visible as identifiers gain digits rather than shrinking.

The default comparator also decides the order of objects and arrays inside the array, which is a place a caller rarely looks. Every object stringifies to "[object Object]", so an array of plain objects sorted with the default comparator is left in its insertion order because every element compares equal. An array of arrays stringifies each inner array to its comma-joined elements, so the outer sort orders by the text of the inner contents rather than by length or by any field. A caller who sorts a list of records with the default comparator and expects a stable, meaningful order gets insertion order for objects and a text order for nested arrays, and neither is the order the caller had in mind. The lesson is that the default comparator is only meaningful for an array of primitives whose string forms order the way the caller wants, which is a narrow case that a mixed or structured array never satisfies.

The default comparator also has a performance shape a caller may not expect. Converting every element to a string costs an allocation per element per comparison in the worst case, and a sort that compares the same pair more than once repeats that work, so a default sort over a large numeric array is slower than a comparator that subtracts. The slowness is not the defect, but it is a symptom of the same root: the default comparator was written to compare strings, and a numeric array pushed through it pays the string conversion cost on every comparison for an order the caller did not want. A code path that switches to a numeric comparator gets both the correct order and less work, which is a rare case where the correct version is also the faster one.

The string order of the default also explains why sorting a list of numbers written as strings looks correct for a while. A list of single-digit strings "1" through "9" sorts to the numeric order, because each string is one character and code-unit order matches numeric order for single digits. The defect only appears once a value has two digits, because then "10" sorts before "2" on the first character. A test fixture built from single-digit values therefore passes with the default sort, and the same code fails on production data that contains a value of ten or more. The gap between a passing fixture and a failing production case is entirely about how many digits the values have, which is not something the sort ever reports.

Why the sort mutates in place

sort is not a pure function of its input: it reorders the array it is called on and returns that same array. The probe sorts a three-element array and checks whether the returned value is the same object as the source:

same reference -> true; array now [1,2,3]

The returned array is the same reference as the input (result === arr is true), and the source array is now 1, 2, 3. A caller who writes const sorted = arr.sort() and then uses arr elsewhere has silently reordered arr, because sorted and arr are the same object. That is a real defect in a code path that keeps the original order for display and also wants a sorted copy, because the sort destroyed the original order in place.

The way to sort without mutating is toSorted, which returns a new array and leaves the source unchanged. The probe confirms the difference: after toSorted the source is still 3, 1, 2 and the returned array is 1, 2, 3, and the two are different references. A caller who wants both the original order and a sorted view needs toSorted or an explicit slice() before sort, not sort on the original.

That mutation is also why a sort inside a render is dangerous. If a component sorts a shared state array to display it, the sort mutates the state on every render, and a later read of the state sees the sorted order rather than the order the state was loaded in. The fix is the same: sort a copy, and record which array the sort touched so a reader knows whether the observed order is the stored order or a transient sort.

The toSorted call above already shows the safe alternative, and it pays for itself whenever the original order matters. A caller who needs both the loaded order and a sorted view uses toSorted, or a slice() followed by sort, and the source stays as it was loaded. The cost is one allocation, which is negligible beside the cost of a state array that mutates under a component that assumed it owned the order.

There is a related trap in a comparator that sorts by a field that can be missing. A comparator that reads a.key - b.key on an element whose key is undefined yields NaN, which the sort treats as equal, so the element keeps its relative position instead of moving to a defined slot. The result is an order where the missing-field rows stay interleaved with the rows they were compared against, which looks sorted at a glance and is not. The fix is to decide where a missing field belongs, sort it to the end with an explicit branch, and record that decision, because the sort itself will not move it.

That in-place mutation is also what makes a sort composed with another mutation unreliable. If a caller sorts one array and then splices a second, and the two share elements by reference, the splice reads the order the sort left, which may not be the order the caller assumed the shared array still had. Sorting in place is a write to shared memory whenever the array object is shared, and a record that does not state whether the array was shared, cloned or freshly built cannot tell whether a later read saw the sorted order or the order before some other code reset it.

The reference identity is also why an equality check after a sort can be misleading. A caller who compares a sorted array to itself to prove "nothing changed" finds them equal, because the sort returned the same object, even though the content changed. A test that wants to prove the sort left the source untouched has to compare against a saved copy taken before the sort, not against the same reference, because the reference equality holds whether or not the sort mutated. The toSorted variant removes the ambiguity by returning a distinct object, so a reference check distinguishes "a copy came back" from "the source was rewritten".

The in-place mutation also changes the meaning of a variable name that promises order. A constant named insertionOrder that is passed to sort and then read later no longer holds insertion order, because the sort rewrote the array behind the name. The name lies about the contents, and a reader who trusts the name reads a sorted order where the code intended an arrival order. Because the mutation is invisible at the call site when the return value is ignored, nothing in the code that reads the name reveals that the order changed, and a record that stores the array under that name stores the sorted order under a label that claims otherwise.

The safest pattern is to sort only inside the function that consumes the sorted result and to pass a clone into that function when the source matters elsewhere. A function that takes an array and returns its sorted order should either document that it sorts in place or return toSorted, and a caller should decide once whether the source array is allowed to change. A code path that mixes both, sorting in place in one place and relying on the original order in another, produces a defect that depends on call order, which is the hardest kind to reproduce because it only fails when the two callers run in a particular sequence.

The mutation also interacts with a shallow copy in a way that surprises a caller who thinks a copy is enough. A slice() copies the array of references but not the elements, so sorting the copy by a field reorders the references without mutating the elements, and the original array keeps its order of the same references. That is safe for the array order, but if the sort then writes back to the elements to record the sorted position, the write reaches the shared elements and the original array's elements change even though its order did not. The distinction between "the array order changed" and "the elements changed" is exactly the distinction a record needs, and neither a sort nor a slice alone states which one happened.

A frozen array is the one case where the in-place mutation fails loudly. Sorting a Object.freezed array throws in strict mode rather than silently reordering, which is the only way the mutation announces itself as a write. A code path that wants to guarantee it never mutates a shared array can freeze it in development, so an accidental sort throws instead of leaving a subtle reorder, and then use toSorted where an order is genuinely needed. The freeze is a cheap way to turn the silent mutation into a loud one, which is the same trade a record makes when it stores whether the source was allowed to change.

Where a stable sort keeps equal keys

The sort is stable: elements that compare equal keep their relative insertion order. The probe sorts four rows with two distinct keys and checks the order of the equal-key rows:

stable sort -> bdac

The input rows are a (key 2), b (key 1), c (key 2) and d (key 1). After sorting by key ascending, the order is b, d, a, c: the two key-1 rows keep b before d, and the two key-2 rows keep a before c. Each equal-key group preserved the order it was inserted in, which is what "stable" means and what lets a caller sort by a primary key, then by a secondary key, and get the expected compound order.

Stability was not always guaranteed; older engines used unstable algorithms and a secondary sort could scramble the first. The specification now requires a stable sort, so a code path that relies on it is correct on any current engine. The practical consequence is that a "sort by column, then sort by another column" workflow is safe only because the sort is stable, and a record that stores the result of a single sort cannot distinguish a stable outcome from an unstable one without also storing the pre-sort order of the equal-key rows.

The stable order is also what makes a multi-key comparator unnecessary in many cases. Instead of a comparator that compares key A then key B, a caller can sort by B first and A second, relying on stability to keep the B order within equal A values. Both approaches give the same order when the comparator is consistent, and the stable two-pass version is easier to read but only correct because the sort is stable.

The stability guarantee also decides what a "sort by column" table can promise after a second click. A table that first sorts by one column and then by another relies on stability to keep the first order within the second, and if the engine were not stable the second click would scatter the first order and the table would show a meaningless order that no column header describes. Because the specification now requires stability, the compound order holds on every current engine, but a record that stores only the final order and not the sequence of sorts cannot prove the compound order was derived the way the table claims.

There is a subtler consequence for a comparator that is stable but not consistent. A stable sort preserves the order of elements the comparator calls equal, so a comparator that wrongly calls two unequal elements equal keeps them in the order they happened to arrive, which can look plausible and be wrong. The stability makes the wrong order look intentional, because a stable sort never scrambles equal elements, so the defect hides behind the very guarantee that makes multi-key sorts work. A record that captures the pre-sort order of the elements the comparator might tie is the only way to tell a correct compound sort from a stable sort over a broken comparator.

The stability also decides whether a sort over already-sorted data is a no-op. A two-pass sort by A then B, run over data that is already in A order, leaves the A order intact within equal B values, so re-running the same two passes changes nothing. A single comparator that reads only B, run over the same data, reorders the rows by B and drops the A ties into B order, which is a different result from the two-pass version even though both are "sorted by B". Which one is correct depends on whether the caller wanted a secondary key or not, and the stable sort is what makes the two-pass version mean what it looks like.

For a record, the pre-sort equal-key order is the field that turns a stable result into a reproducible one. The final sorted array alone does not say which of two equal-key elements came first, because both orders are valid outcomes of a stable sort over the same input depending on the order they arrived in. Only the pre-sort order plus the comparator plus the stability guarantee pins the result down, and a record that stores the final array without the pre-sort order has stored one of several valid outcomes and called it the outcome.

Stability also matters for a deduplication that follows a sort. A code path that sorts an array and then removes adjacent duplicates to keep the "first" of each run relies on stability to keep the element that arrived first at the front of each run. On a stable engine the first-arriving element wins the deduplication, and on an unstable engine a different one could win, which changes which value survives. Because the sort is now stable, the first-arriving element always survives, and a record that stores the deduplicated result without the pre-sort order cannot say which element was kept or why.

The two-pass compound sort has a cost that a single comparator hides. Sorting twice runs the algorithm twice, and the second pass reorders the whole array by the primary key while the stability preserves the secondary order within ties. A single comparator that compares primary then secondary does the work in one pass and gives the same result, but it must handle the secondary comparison itself and stay consistent across all three fields it now compares. Whichever approach a code path picks, the record has to say which one it used, because a two-pass result and a one-pass result over the same data agree only when the comparator is a consistent total order over the compound key.

Stability is also what makes a paginated sort safe. A server that returns page two of a sorted list relies on a stable order so the rows on page two do not repeat a row from page one when two rows tie on the sort key. Without stability the tie order is arbitrary per page, and a row can appear on both pages or on neither, which is a classic pagination bug that no error reports. The stable sort makes the tie order deterministic within one pass, but across two separate requests the tie order depends on the order the server read the rows, which is why a stable client sort cannot rescue a page that the server built with an unstable order. The record has to capture which side sorted and whether that side was stable.

Where undefined and NaN land

undefined, array holes and NaN do not behave like ordinary numbers under a sort, and the probe shows where each one goes:

[3,undefined,1].sort() -> [1,3,null]
[3,undefined,1].sort((a,b)=>a-b) -> [1,3,null]
[1,null,3].sort((a,b)=>a-b) -> [null,1,3]
[3,NaN,1].sort((a,b)=>a-b) -> [3,null,1]

The printed null in the first two rows is the serializer's view: JSON.stringify renders an array slot that holds undefined (after a sort that moved it to the end) as null, because an array cannot hold undefined in JSON. The stored value is undefined at the end, and the sort moved it there because the default sort treats undefined as greater than every defined element and always sorts it last. The comparator form does the same, since the spec sends undefined to the end regardless of the comparator.

The null row is a genuine value: null compares as 0 under a - b, so [1, null, 3] sorts to null, 1, 3, with null at the front as if it were zero. The NaN row is the sharpest: NaN compared with any other value returns NaN, which the sort treats as 0, so NaN does not move relative to the elements it was compared with and the array keeps its 3, NaN, 1 order. A comparator that returns NaN for a whole sort is treated as "equal" for every pair, so the order is preserved.

Those two behaviours are why a sorted record must name whether it holds undefined, holes or NaN. A sorted list of numbers that silently contains a NaN does not sort to the numeric order a reader expects, because the NaN stays where it was and everything else sorts around it. A record that stores only the printed array, with the NaN already turned to null by the serializer, cannot be re-run to the same order without knowing the NaN was there.

The NaN behaviour has a second face that matters when the input comes from arithmetic. A comparison that produces NaN from an overflow, an invalid parse or a division of zero by zero returns NaN to the sort, which treats it as equal, so the element does not move and the order stays as it was. A list of computed scores that ends up with one NaN therefore does not sort the NaN to the end the way a missing value might; it leaves the row where it was inserted and sorts the surrounding rows as if the NaN were a tie. A reader who assumes all numerical failures sort to the top or bottom is surprised, because NaN does neither and the record has to name it.

The undefined-to-the-end rule is the one exception the specification states explicitly: however the comparator behaves, undefined and holes are treated as greater than any defined value and always sort last. That is why the comparator form and the default form agree on where undefined goes even though they disagree on everything else. For a record, the practical point is that an array with a trailing hole and an array with a trailing undefined look the same after a sort and after a JSON.stringify, so a reader cannot tell from the stored form which one the source held. The distinction only survives if the record states it before the sort.

The interaction between undefined and the comparator is worth one more step, because it decides whether a comparator over mixed values behaves like the default or not. A comparator that adds or subtracts on a defined value and an undefined value produces NaN, and the spec sends undefined to the end before the comparator is even consulted, so the arithmetic never runs for that pair. That means a comparator that would fail on undefined in a plain loop is safe inside a sort, because the sort removes the undefined elements first. A caller who pre-filters undefined out of the array before sorting, then, has done work the sort would have done anyway, and a caller who leaves them in gets a defined, reproducible result rather than an exception.

The null value is the case that behaves neither like undefined nor like a number a reader would expect. null coerces to 0 under subtraction, so a comparator sorts it to the numeric front, and a caller who treats null as "missing" and expects it at the end finds it at the beginning. The distinction between "missing as undefined" and "missing as null" therefore changes the sort result, and a record that stores only the printed array with both rendered as null cannot tell which representation the source used. A reader who re-runs the sort on the stored form gets a different order from the original whenever the two representations were mixed, because one sorted to the end and the other to the front.

The NaN case also changes how a sort composes with a filter. A code path that sorts, then filters out the front element, relies on the sort putting a known element at the front; if the array contains a NaN that stayed in place, the front element is not the minimum, and the filter removes the wrong element. The NaN sits wherever it was inserted and the sort leaves it there, so the front of the sorted array is the smallest of the elements the sort actually moved, which may be a different element than the minimum of the whole array. A record that notes the presence of a NaN is what lets a reader know the front element is not necessarily the minimum, and a code path that filters before sorting avoids the trap entirely.

The undefined, hole and NaN cases share one lesson for a fixture: test the array the way it actually arrives, not the way it looks after a cleanup step. A fixture that pre-sorts a cleaned array passes, and the same code on an array with a trailing undefined or a stray NaN behaves differently without throwing. The cleanup step hides the very input the sort handles specially, and a record that stores the cleaned array has stored the version the sort treats as ordinary, not the version the production path feeds it.

Why a non-consistent comparator yields a wrong order

The sort algorithm assumes the comparator describes a total order: if it says a is not greater than b and b is not greater than c, then a is not greater than c, and a compares equal to itself. A comparator that breaks this can produce a wrong order without throwing. The probe sorts with a comparator that only compares parity and shows both a benign and a broken case:

sort(()=>0) keeps order -> [3,1,2,5,4]
non-transitive comparator -> [2,4,6,8,10,1,3,5,7,9,11]

sort(() => 0) says every pair is equal, so the algorithm leaves the array as it was: 3, 1, 2, 5, 4. That is harmless and predictable. The non-transitive comparator (a, b) => (a % 2) - (b % 2) says every even number is less than every odd number, but it is not consistent: an even, an odd and another even can compare in a circle. The result 2, 4, 6, 8, 10, 1, 3, 5, 7, 9, 11 puts all evens first and odds after, but within the odd group the order 1, 3, 5, 7, 9, 11 and within evens 2, 4, 6, 8, 10 happen to be ascending only because the input was; a different input can leave the evens scrambled, and nothing throws.

A comparator that returns a value larger than the double-precision gap between the two numbers has the same failure mode from a different cause. The probe sorts three values around 2^53 with a subtraction comparator:

big subtract sort -> ["-9007199254740992","1","9007199254740992"]

2^53 is the largest integer below which every integer is exactly representable as a double, and an integer one above it is not representable, so (2^53) - (2^53 + 1) cannot equal -1; it rounds to 0, and the comparator reports two unequal numbers as equal. A subtraction comparator over that range therefore gives an arbitrary but stable order rather than the numeric order, and the failure is silent. The fix is to write the comparator with explicit less-than and greater-than branches that return -1, 1 or 0, rather than subtracting.

The overflow is silent in the same way the string order is silent, and it is worth stating why the two share a root cause. Both defects come from trusting a comparison that is cheap to write and wrong on part of its input: the default comparator trusts the string form of a number, and a subtraction comparator trusts that the difference fits in a double. Both produce a stable, non-throwing order that looks sorted for the inputs a developer happens to test and diverges on the inputs that matter, so neither is caught by a test that only checks the code ran. The fix for both is to make the comparison explicit and total over the real domain, and to record the domain so a later reader knows which inputs the comparator was validated against.

The consistency requirement and the overflow requirement meet in the same place: a comparator has to define a total order over the values it will actually see. A comparator written for numbers that receives a NaN, a null, an undefined or a string is no longer a total order over its input, and the sort will produce an order that a strict comparison test rejects without raising anything. The remedy is to normalize the inputs before the sort, or to write a comparator that handles each type explicitly, and then to record which types were handled, because the sort itself never complains about a comparator it cannot trust.

Record a sort reading

A sort reading supports a narrow claim: this runtime, this array, with this comparator, produced this order. Six fields keep it checkable, and none of them assumes the array is numeric or the comparator is consistent:

  1. The comparator, recorded as source text or as the choice "default", because the default sort orders by string while any explicit comparator defines its own order.
  2. Whether the sort mutated the source array or returned a copy, because sort mutates in place and toSorted returns a copy, and a stored order without this cannot be attributed to the right array.
  3. The pre-sort order of any equal-key group, because a stable sort preserves it and the stored result alone does not show which equal element came first.
  4. Whether the array held undefined, holes or NaN, because they sort to the end or stay in place rather than following the numeric order, and a serializer turns them into null.
  5. Whether the comparator is a consistent total order, because a comparator that is non-transitive or that overflows produces a wrong but non-throwing order.
  6. The element types, because a mixed array sorts by each element's string form under the default comparator, not by any shared numeric order.

What such a reading cannot show is anything about how a server or a second runtime will order the same data. A page sorts its own array, but a server that returns a pre-sorted list may have used a different comparator, and a stable sort on the client cannot recover an order the server did not send. For the key order a serializer gives an object, Object key order shows why integer keys sort to the front while string keys keep insertion order, and for the container a copy keeps, structuredClone versus JSON shows which types survive a deep copy and which a JSON round trip collapses.

Two habits keep the reading honest over time. First, always pass an explicit comparator for numbers and write it with less-than and greater-than branches rather than subtraction, so a large gap cannot round to equal. Second, record whether the sort mutated the source and what the equal-key order was before the sort, so a stored order is attributable and a stable result is distinguishable from a lucky one.

Public sources

Practical conclusion

Treat the comparator as part of the data contract: the default sort orders by string and turns [10,9,1,100] into [1,10,100,9], so always pass an explicit numeric comparator, written with less-than and greater-than branches rather than subtraction so a gap past 2^53 cannot round to equal. Know that sort mutates in place and toSorted returns a copy, that the sort is stable so equal keys keep insertion order, that undefined and holes sort to the end while a NaN stays in place, and that a comparator which is not a consistent total order gives a wrong but silent result. Record the comparator, whether the source mutated, the pre-sort equal-key order and any undefined, hole or NaN, so a stored order is attributable and a stable outcome is distinguishable from a lucky one.

Next reading

For the key order a serializer gives an object, read Object key order; for which container types survive a deep copy before any sort runs, read structuredClone versus JSON.

#Array.sort#Comparator#Stable Sort#NaN#Integer Overflow

This article is part of the guide: What Is a Browser Fingerprint?

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.