Back to Knowledge Hub
Fingerprint

Object Key Order: Integer Keys Sort, String Keys Stay

Build objects with mixed keys: integer keys sort ascending, string keys keep insertion order, which keys count as integers, and how JSON, Map, for-in and spread follow the order.

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 key order a page iterated and the string it serialized, but it cannot change how a runtime orders ordinary object keys, cannot make an integer key keep insertion order, and cannot turn a key order into an identity signal. This review builds objects with mixed keys and reads them back through Object.keys, JSON.stringify, for...in, Map and spread, then records why integer-like keys sort ascending while string keys keep insertion order, which keys count as integers, why for...in differs from Object.keys, and where Map and JSON diverge from the ordinary object rules.

TL;DR

  • Integer-like keys always come first and sort ascending, so inserting b, 2, a, 10, 1 reads back as ["1","2","10","b","a"] regardless of the order they were written.
  • Only keys that are a canonical integer in 0 to 4294967294 count; 01, -1 and 1.5 are string keys and keep insertion order.
  • String keys keep insertion order, symbols come after every string key and are skipped by Object.keys.
  • JSON.stringify follows the same order, so a mixed object serializes with integer keys first even when they were added last.
  • for...in includes inherited enumerable keys; Object.keys returns only own keys.
  • Deleting and re-adding a string key moves it to the end, while an integer key re-sorts back into its numeric slot.
  • Map keeps insertion order even for integer-like keys, so the same keys in a Map and in an object read back in different orders.

Contents

One object with keys inserted B, 2, A, 10, 1 reads back as 1, 2, 10, B, A because integer-like keys sort ascending while string keys keep insertion order; a separate MAP keeps insertion order B, 2, 1, and a JSON column shows integer keys first even when added last

Why integer-like keys sort before string keys

An ordinary object does not iterate keys in the order they were written. The order the language defines is: integer-like keys first in ascending numeric order, then the remaining string keys in insertion order, then symbols in insertion order. The probe writes five keys with a mix of integer and string keys and reads them back:

Object=function Map=function node=v26.9.0
insert b,2,a,10,1 -> keys ["1","2","10","b","a"]

The keys were inserted b, 2, a, 10, 1, but Object.keys returns 1, 2, 10, b, a. The three integer-like keys 2, 10 and 1 come first, sorted as numbers so that 1 precedes 2 precedes 10 rather than sorting as text where 10 would precede 2. The two string keys b and a follow in the order they were added. Nothing was reordered by hand; the runtime applied the specification's order on every read.

That numeric sort is why 10 comes before 2 is the wrong intuition: as text "10" sorts before "2", but as an index it sorts after. A record that compares two key lists assumes they are comparable only if both were built the same way, because an object built by insertion and an object built by JSON.parse of the same text can iterate in the same order while an object whose integer keys were added later still sorts them first.

The practical consequence is that a key order recorded from an object is not the insertion order. A caller who adds b then 2 and reads back 2 then b sees a reorder, and a diff of two objects built with the same keys in different insertion orders is empty because the integer keys dominate the front of both. A record of an object's keys with the word "insertion order" in it is therefore wrong whenever the object holds integer-like keys.

The reorder also decides the first key an iterator returns, and a reader that takes Object.keys(o)[0] as "the first field the page set" reads the wrong field whenever any integer-like key exists. The front of the list is the smallest integer key, not the earliest inserted one, so the same code path returns a different field for an object with a numeric key and for one without. That is a real defect in a code path that infers field priority from position, because the inference is only valid for objects whose keys are all non-integer strings.

Which keys count as integers

Not every numeric-looking string is an integer key, and the boundary decides which keys sort and which keep insertion order. The probe writes five numeric-looking keys that are not canonical indices:

non-index keys order -> ["4294967294","01","-1","1.5","4294967295"]

4294967294 is 2^32 - 2, the largest canonical array index, and it sorts to the front as an integer key. The keys 01, -1 and 1.5 are not canonical integers (01 has a leading zero, -1 is negative, 1.5 is not whole), so they are ordinary string keys and keep insertion order after the integer key. The key 4294967295 is 2^32 - 1, one past the largest index, so it does not count as an integer key and stays a string key at the end. The boundary is exact: 0 to 4294967294 are indices, and 2^32 - 1 is not.

That boundary is why a numeric-looking key can silently change the order of a whole object. A code path that uses a big integer as an id and adds it to an object places it at the front in numeric order, while the same id written with a leading zero or a sign becomes a string key that stays where it was inserted. Two objects that a reader would call "the same data" then iterate in different orders, and any code that reads the first key or relies on position sees a different value.

The rule that keeps a code path safe is to use a Map when insertion order matters and to treat object key order as untrustworthy for anything except the integer-then-string rule. A record that claims to preserve a server's field order through an ordinary object is wrong for any field whose name is a canonical integer, because the object will move that field to the front regardless of where the server wrote it.

How for-in differs from Object.keys

Two seemingly identical loops read a different set of keys, and the difference is inheritance. The probe builds a child object whose prototype holds one key and then compares the two iterators:

for...in -> ["own","inherited"]
Object.keys -> ["own"]

for...in walks the child's own keys and then the enumerable keys it inherits from its prototype chain, so it lists own and inherited. Object.keys returns only the object's own enumerable string keys, so it lists only own. An object created with a prototype carrying defaults will therefore list those defaults in a for...in loop and not in Object.keys, and a serializer built on for...in will copy inherited fields into the output while one built on Object.keys will not.

That gap is the source of a subtle leak: a for...in loop over an object that inherits from a populated prototype reads fields the caller never set, and a record that stores the loop output attributes those fields to the object. Object.keys, Object.values and Object.entries all take the own-key view, so a record built on them describes only what the object owns. The two iterators also differ on symbols: for...in never visits symbol keys and Object.keys never returns them either, so neither one sees the symbol in the earlier probe, and only Reflect.ownKeys or Object.getOwnPropertySymbols reaches symbols.

How JSON, Map and spread follow or break the order

The serializers and containers a page uses inherit or override the ordinary object order, and the probe shows three behaviours. JSON follows the object order, spread copies in the same order, and a Map keeps insertion order even for integer-like keys:

JSON.stringify order -> {"2":2,"b":1,"a":3}
JSON.stringify mixed -> {"2":"y","10":"x","z":"w"}
Map keys -> ["b","2","1"]
Object with same keys -> ["1","2","b"]
spread copy order -> ["2","b","a"]

JSON.stringify of an object with keys b, 2, a writes 2 first because integer-like keys sort to the front, so the serialized text does not reflect the order the fields were set. The mixed object serializes 2 then 10 then z, again integer keys first in numeric order. A page that diffs two serialized bodies for equality sees the integer keys always in the same front position, which hides an insertion-order difference the object might otherwise have shown.

The Map lines are the contrast that matters. A Map with keys b, 2, 1 read by [...map.keys()] returns b, 2, 1, the insertion order, because a Map iterates entries in the order they were set and does not sort integer-like keys. Converting that Map to an object with Object.fromEntries reorders the same keys to 1, 2, b, so a round trip through an object changes the order a Map preserved. A record that stores a Map as an object and reads it back has silently replaced insertion order with the integer-then-string rule.

That divergence is why the choice of container is a data-contract decision, not a style preference. A page that receives a list of settings, stores them in a Map to keep the arrival order, and then serializes them as an ordinary object for storage has discarded the arrival order at the moment it converted. A reader that later loads the stored object and reads the first key sees the smallest integer key, not the first setting that arrived, and nothing in the stored form records which one it was. Keeping the Map as a Map, or storing the key order as an explicit array beside the values, is what preserves the fact the object dropped.

The spread line confirms the copy keeps the object order: spreading {b:1, 2:2, a:3} into a new object yields keys 2, b, a, integer key first. Spread copies own enumerable string keys in enumeration order, so it preserves the reordering rather than the original insertion. A caller who builds a cleaned copy by spreading an object and expects the field order to match the source is right only for objects with no integer-like keys.

Where delete and re-add move a key

Deleting a key and adding it again changes its position, and the change depends on whether the key is an integer. The probe deletes and re-adds one string key and one integer key:

delete+readd string -> ["b","c","a"]
delete+readd integer -> ["3","5"]

For the string-key object {a, b, c}, deleting a and re-adding it moves a to the end, so the order becomes b, c, a. That is the signature of an insertion-ordered key: removing it forgets its slot, and re-adding appends it. For the integer-key object with keys 5 and 3, deleting 3 and re-adding it leaves the order 3, 5, because an integer key sorts back into its numeric slot regardless of when it was added. The delete-move-to-end behaviour applies only to string keys, and integer keys are immune to it.

That difference is why a "delete the key to bump it to the end" approach works for string keys and fails for integer keys. A cache that deletes and re-adds a hot entry to keep it last in an object works only while the key is a string; the moment the key is a canonical integer the entry snaps back to its numeric position and the approach silently stops working. The same asymmetry shows up in an array: an array's indices are integer keys, so an array always iterates in index order, which the probe confirms for a plain array with one extra string property.

Record a key reading

A key reading supports a narrow claim: this runtime, this object, iterated with this method, produced this key list. Six fields keep it checkable, and none of them assumes the list is insertion order:

  1. The iteration method, Object.keys, for...in, Reflect.ownKeys, JSON.stringify or a Map iterator, because each returns a different set or order.
  2. The keys as returned, with the integer-like keys already in numeric order, because the raw list is the only faithful record and a re-derived "insertion order" is wrong once an integer key is present.
  3. Whether the object has a prototype that contributes enumerable keys, because for...in reads them and Object.keys does not.
  4. Whether symbol keys were requested, because Object.keys and for...in skip them and only Reflect.ownKeys includes them.
  5. For a Map, the insertion order as set, because a conversion to an object and back reorders integer-like keys.
  6. Whether any key was deleted and re-added, because a string key moves to the end while an integer key re-sorts, and a record that omits this cannot explain a moved key.

What such a reading cannot show is anything about how a server produced the same keys. A server that writes JSON in a field order can be re-serialized by a client runtime that moves integer-like keys to the front, so the order the server chose is not the order the page reads. For the copy side of the same objects, structuredClone versus JSON shows which container types survive a deep copy and which a JSON round trip collapses, and for the text a regex scans over the serialized form, RegExp lastIndex and flags shows why a reused global pattern flips its answer.

Two habits keep the reading honest over time. First, record the raw key list from the exact method used, because the integer-then-string rule makes a claimed insertion order wrong for any object with a numeric key. Second, use a Map when insertion order is part of the data, because an ordinary object reorders integer-like keys on every read and on every round trip through JSON and spread.

Public sources

Practical conclusion

Treat object key order as the integer-then-string rule, not insertion order: integer-like keys sort ascending and come first, only canonical integers from 0 to 4294967294 count, and 01, -1, 1.5 and 4294967295 stay string keys. Know that JSON.stringify and spread follow that order, that Map keeps insertion order even for integer-like keys so a round trip through an object reorders them, and that for...in reads inherited keys where Object.keys does not. Deleting and re-adding moves a string key to the end but leaves an integer key in its numeric slot. Record the exact method and the raw key list, and use a Map when insertion order is part of the data, so a claimed field order never contradicts the order a runtime actually reads.

Next reading

For which container types survive a deep copy before any key is read, read structuredClone versus JSON; for why a reused global pattern over the serialized text flips its answer, read RegExp lastIndex and flags; for the order a sort gives an array of those keys, read Array sort and comparators.

#Object.keys#Enumeration Order#JSON.stringify#Map#Property Order

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.