JavaScript's Object.keys Integer Key Trap: The Insertion Order That Isn't

2026-05-26

This function tracks the order in which API endpoints first appear in a request log. It returns the list of unique endpoint names in first-seen order, which the downstream scheduler uses to round-robin between them fairly.

// Returns unique endpoints in the order they were first seen.
function endpointOrder(requests) {
    const counts = {};
    for (const endpoint of requests) {
        counts[endpoint] = (counts[endpoint] || 0) + 1;
    }
    return Object.keys(counts);
}

const log = [
    'api/users',
    'api/posts',
    '404',          // numeric error-code "endpoint" used by a legacy probe
    '503',
    'api/login',
    'api/users',
];

console.log(endpointOrder(log));
// Expected: ['api/users', 'api/posts', '404', '503', 'api/login']
// Actual:   ['404', '503', 'api/users', 'api/posts', 'api/login']

The function looks airtight. Modern JavaScript engines have preserved object property insertion order since ES2015, so iterating Object.keys should just give back what you put in, right?

The Bug

JavaScript's "insertion-order" guarantee for plain objects has a sharp asterisk that bites surprisingly often. The actual OrdinaryOwnPropertyKeys algorithm orders keys in three buckets:

  1. Integer-indexed keys — string keys that round-trip through ToString(ToUint32(key)) (i.e., look like canonical non-negative 32-bit integers) — appear first, in ascending numeric order.
  2. Other string keys — in insertion order.
  3. Symbol keys — in insertion order.

So when you set counts['404'], the engine notices '404' is an integer-index key and silently slots it ahead of any string key, sorted against other integer-index keys. '404' < '503' numerically, so they land in that order — even though '503' was inserted later, and both came after the api/* keys.

This bites anywhere user-controlled strings can resemble integers: HTTP status codes used as map keys, ZIP codes, product SKUs that happen to be all digits, year strings, language tag fragments. Your tests pass with alphanumeric fixtures and break in production the day someone logs in with username "42".

Worse, JSON.stringify uses the same key order, so serialized output silently reorders too — breaking any consumer that signs or hashes a canonical JSON form.

The fix is to stop using plain objects as ordered maps. Map preserves true insertion order for all key types, including numeric strings, and doesn't conflate property keys with array indices:

function endpointOrder(requests) {
    const counts = new Map();
    for (const endpoint of requests) {
        counts.set(endpoint, (counts.get(endpoint) || 0) + 1);
    }
    return [...counts.keys()];
}

If you must keep an object, maintain insertion order separately (e.g., a parallel array of keys), or prefix numeric keys with a non-digit sentinel ('k:404') so the engine doesn't classify them as integer-indexed.

Note that the bucket boundary is the canonical 32-bit unsigned integer form. '01', '4.0', '-1', and '4294967296' are not integer-indexed and stay in insertion order. '0', '42', and '4294967294' are. This irregularity makes the bug feel random when you first encounter it: the same code reorders some numeric strings and not others.

Key Takeaway: Plain JavaScript objects preserve insertion order only for non-integer-index string keys — use Map whenever your keys might be numeric strings and order matters.

All newsletters