JavaScript Object.freeze Is Shallow: Stop Nested Mutation
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=trueThe 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
| Requirement | Operation | Consequence checked above |
|---|---|---|
| Keep the outer reference fixed | Freeze the outer object | Child replacement throws; child mutation succeeds |
| Reject child-field writes through every alias | Freeze the existing child too | The original child reference also rejects mutation |
| Protect a separate child record | Copy the child, then freeze the copy and outer object | Identity differs; writes to the copied child throw |
| Keep array membership fixed | Freeze the array | push 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: