543 words
3 minutes

JavaScript Object.freeze Is Shallow: Stop Nested Mutation

2026-11-02
Frontend
JavaScript
/
Node.js
/
Troubleshooting
Contents

Object.freeze(settings) does not freeze settings.retry. It prevents changes to the outer object’s own properties, including replacing the retry reference. The object reached through that reference remains mutable until you freeze it separately.

For a known settings shape, freeze each child that must reject mutation. If other code owns that child, copy it before freezing: freezing an existing object affects every reference to it. The distinction is between protecting a property and protecting the object stored in that property.

Check the object that actually changes#

The ECMAScript Object.freeze algorithm applies the frozen integrity level to the supplied object. It does not recursively call itself on property values. MDN’s freeze reference describes this shallow boundary.

This explains an apparently contradictory pair of observations: Object.isFrozen(settings) returns true, yet settings.retry.attempts++ succeeds. Check Object.isFrozen(settings.retry) too. The first check says nothing about the second object.

Run the following as freeze-check.mjs with node freeze-check.mjs. The assertions and output were verified with Node.js 24.13.0 on macOS on November 2, 2026. ES modules use strict mode, so rejected assignments throw rather than failing silently.

import assert from 'node:assert/strict';
const child = { count: 0 };
const outer = Object.freeze({ child });
assert.equal(Object.isFrozen(outer), true);
assert.equal(Object.isFrozen(child), false);
assert.throws(() => { outer.child = {}; }, TypeError);
child.count++;
assert.equal(outer.child.count, 1);
Object.freeze(child);
assert.throws(() => { child.count++; }, TypeError);
const items = Object.freeze([{ count: 0 }]);
assert.throws(() => items.push({ count: 2 }), TypeError);
items[0].count++;
assert.equal(items[0].count, 1);
const independent = Object.freeze({ child: Object.freeze({ ...child }) });
assert.notEqual(independent.child, child);
assert.throws(() => { independent.child.count++; }, TypeError);
console.log('outer=true child-before=false shared-count=1 array-item-count=1 copied-child=true');

Expected output:

outer=true child-before=false shared-count=1 array-item-count=1 copied-child=true

The fixture separates four operations: replacing an outer property, changing a child field, adding an array entry, and changing a field inside an existing entry. A frozen array rejects the new entry while its unfrozen element still accepts a field change.

Choose which objects your code owns#

RequirementOperationConsequence checked above
Keep the outer reference fixedFreeze the outer objectChild replacement throws; child mutation succeeds
Reject child-field writes through every aliasFreeze the existing child tooThe original child reference also rejects mutation
Protect a separate child recordCopy the child, then freeze the copy and outer objectIdentity differs; writes to the copied child throw
Keep array membership fixedFreeze the arraypush throws; an unfrozen element can still change

The independent record in the fixture is a copy of the child’s current value: its count is already 1. Copying does not recover an earlier snapshot. Construct the protected copy before handing mutable state to code that may change it.

For ordinary records with known primitive fields, an explicit copy keeps the ownership decision visible. If a child itself contains another object, spreading that child still shares the deeper reference. The same identity issue appears in nested object spread; reconstruct every level that needs independence.

Avoid introducing a generic recursive freezer solely to fix one known shape. This example does not validate arbitrary object graphs, cycles, getters, proxies, class instances, or collection internals. A recursive implementation needs an explicit policy for those inputs. Freezing also does not clone data or make an existing shared object private.

For this fixture’s plain records, inspect the exact mutation target, decide whether shared aliases should be affected, and freeze the owned objects at each required level. Keep the identity and rejected-write assertions beside the code that constructs the settings so a later nested field does not silently expand the mutable boundary.

References:

JavaScript Object.freeze Is Shallow: Stop Nested Mutation
https://laplusda.com/en/posts/javascript-object-freeze-nested-mutation/
Author
Zero
Published at
2026-11-02
License
CC BY-NC-SA 4.0