Integer-like object keys jump ahead of insertion order
published
TL;DR
Object property order is insertion order only for keys that are not array indices. Any key that is a canonical integer string in the range 0 to 2^32 - 2 is hoisted to the front and sorted numerically, by Object.keys(), for...in, JSON.stringify(), object spread and Object.entries() alike. If your keys are numeric ids, the object silently re-sorts itself. Use a Map, or prefix the keys so they stop looking like indices.
The problem
You build a lookup keyed by id, in the order the API returned it:
const byId = {};
byId['1001'] = 'Ada';
byId['42'] = 'Grace';
byId['zed'] = 'Alan';
console.log(Object.keys(byId));
[ '42', '1001', 'zed' ]
42 was inserted second and comes out first. The same reordering survives serialization, which is where it usually gets noticed — a fixture file, a snapshot test or an API payload comes back in an order nobody wrote:
JSON.stringify(byId);
// '{"42":"Grace","1001":"Ada","zed":"Alan"}'
Spread does not rescue it either — { ...byId } copies properties by the same ordering rules, so the clone has the same reordered layout.
Why it happens
The spec’s OrdinaryOwnPropertyKeys defines a fixed three-part order for own properties:
- Array-index keys, in ascending numeric order.
- Other string keys, in property-creation order.
- Symbol keys, in property-creation order.
An array index is narrower than “a key that looks like a number”. It must be the canonical string form of an integer in the range 0 to 2^32 - 2. That excludes more than people expect:
| Key | Array index? | Where it lands |
|---|---|---|
'42' | yes | front block, numeric order |
'4294967294' | yes (2^32 - 2, the last valid index) | front block |
'4294967295' | no (2^32 - 1 is out of range) | insertion order |
'01' | no (not canonical — String(1) is '1') | insertion order |
'1.0' | no (not canonical) | insertion order |
'-1' | no (negative) | insertion order |
Run all of them through one object and the split is visible:
const o = {};
for (const k of ['1001', '42', 'zed', '-1', '01', '4294967295', '4294967294']) o[k] = true;
console.log(Object.keys(o));
// [ '42', '1001', '4294967294', 'zed', '-1', '01', '4294967295' ]
Three integer-ish keys sorted to the front; the other four kept the order they were written in, including the two that merely look numeric.
This is not an engine quirk to be fixed. Property order was left implementation-defined for years, and when TC39 standardised it in ES2015 it standardised the behaviour engines already had, which existed so that objects with dense numeric keys could be stored the way arrays are.
What to do
Use a Map when the keys are data. Map preserves insertion order for every key type, and it does not coerce keys to strings:
const byId = new Map([
[1001, 'Ada'],
[42, 'Grace'],
]);
[...byId.keys()]; // [ 1001, 42 ]
If it must stay a plain object — because it is JSON on the wire — stop the keys from looking like indices, or stop depending on order:
// 1. prefix: 'id:1001' is not an array index, so insertion order holds
const byId = { 'id:1001': 'Ada', 'id:42': 'Grace' };
// 2. carry the order explicitly, which survives any serializer
const payload = { order: ['1001', '42'], items: { 1001: 'Ada', 42: 'Grace' } };
// 3. use an array of entries when order IS the meaning
const rows = [{ id: 1001, name: 'Ada' }, { id: 42, name: 'Grace' }];
Sort at read time rather than trusting the object. If you only need a stable order for display or for a diff, sort explicitly — an explicit comparator is also the only way to get numeric order for keys above 2^32 - 2:
Object.keys(byId).sort((a, b) => Number(a) - Number(b));
Caveats
Object.getOwnPropertyNames(),Reflect.ownKeys(),for...inandJSON.stringify()all follow the same rule; there is no ordering escape hatch among them.Object.assignand spread copy in that order too.for...inalso walks the prototype chain, and only the own-property portion is covered by this ordering guarantee. Inherited enumerable keys come after, per prototype level.JSON.parsedoes not reorder anything by itself — it inserts keys in document order, and the object then applies the same index-first rule. So a JSON file whose keys are numeric ids will not round-trip in its original order.- Numeric keys are still strings on a plain object:
o[42]ando['42']are the same property. That is a separate footgun from ordering, and it is whyMapis the better container for numeric ids. - Arrays are unaffected in practice, because their indices are already the sorted front block — this only bites when you use an object as a sparse or id-keyed map.