Object.freeze Shallow Trap: The "Frozen" Config That Mutates Between Requests2026-08-22
This module exposes a frozen default config plus a helper that returns a shallow-merged copy. The intent is airtight: callers can override top-level keys, but nothing they do should ever leak into another request's config. Object.freeze is right there. What could go wrong?
const DEFAULT_CONFIG = Object.freeze({
version: 1,
retry: {
maxAttempts: 3,
backoffMs: 500,
},
features: {
darkMode: false,
beta: [],
},
});
function withOverrides(overrides) {
// Shallow merge is fine — DEFAULT_CONFIG is frozen, so nobody
// can accidentally mutate the shared defaults. Right?
return { ...DEFAULT_CONFIG, ...overrides };
}
// --- Request A ---
const a = withOverrides({ version: 2 });
a.features.beta.push("newSearch");
a.retry.maxAttempts = 10;
// --- Request B, an hour later, totally unrelated ---
const b = withOverrides({});
console.log(b.retry.maxAttempts); // Expected 3
console.log(b.features.beta); // Expected []
Request B logs 10 and ["newSearch"]. Request A has permanently poisoned the "frozen" defaults, and every subsequent request inherits its mutations.
Object.freeze is shallow. It freezes only the immediate properties of the object you pass. The values of retry and features are references to separate objects that were never frozen. MDN says this plainly, but the API name lies about it — freeze sounds recursive, and the linter doesn't warn you.
Then the spread makes it worse. { ...DEFAULT_CONFIG, ...overrides } is a shallow copy: it copies the top-level property values, which for objects means copying the reference. So after the spread, a.retry === DEFAULT_CONFIG.retry. Mutating a.retry.maxAttempts writes straight through to the shared object that Request B will also receive.
Worse still: in non-strict mode, writing to a frozen property fails silently. If retry had been frozen, a.retry.maxAttempts = 10 would have no effect but throw no error. You'd have a different bug — silently-lost overrides — masquerading as the same code working correctly.
Two options, in order of preference:
function withOverrides(overrides) {
// structuredClone gives each caller an independent deep copy.
const cfg = structuredClone(DEFAULT_CONFIG);
return Object.assign(cfg, overrides);
}
// Or, if you truly want frozen-all-the-way-down defaults:
function deepFreeze(obj) {
for (const key of Object.keys(obj)) {
const v = obj[key];
if (v && typeof v === "object") deepFreeze(v);
}
return Object.freeze(obj);
}
const DEFAULT_CONFIG = deepFreeze({ /* ... */ });
The subtlety that catches teams: this bug is invisible in tests. Each test file gets a fresh module load, so DEFAULT_CONFIG resets between test runs. It only manifests in a long-lived Node process — a server handling multiple requests — where request N sees the accumulated mutations of requests 1 through N-1. Load testing catches it; unit tests never will.
Object.freeze only freezes one level deep, and a spread copies references — so a "frozen" default with nested objects is a shared mutable singleton dressed up as immutable.
