Object.freeze is shallow, and a frozen Map still accepts writes
published
TL;DR
Object.freeze freezes exactly one level: the own properties of the object you passed. Anything those properties point at is a different object and stays fully mutable. Worse, freezing a Map, Set or Date does nothing useful at all — their contents live in internal slots rather than properties, so set, add and setTime keep working on a frozen instance. Object.isFrozen returns true the whole time.
The problem
You freeze a config object, ship it as the immutable defaults, and something reaches in and changes it anyway.
const config = Object.freeze({
name: 'api',
retries: 3,
db: { host: 'localhost', port: 5432 },
tags: ['a', 'b'],
});
console.log('isFrozen(config):', Object.isFrozen(config));
console.log('isFrozen(config.db):', Object.isFrozen(config.db));
config.db.host = 'evil.example';
config.tags.push('c');
console.log('after nested writes:', JSON.stringify(config));
On Node v26.2.0:
isFrozen(config): true
isFrozen(config.db): false
after nested writes: {"name":"api","retries":3,"db":{"host":"evil.example","port":5432},"tags":["a","b","c"]}
Both writes went through. No error, no warning. The object still reports itself as frozen, because it is — config has exactly the four own properties it started with, and none of them was reassigned. What changed was the inside of two other objects that config merely points at.
Only the top level is actually protected:
config.retries = 99;
// TypeError: Cannot assign to read only property 'retries' of object '#<Object>'
Why it happens
Object.freeze runs the spec operation SetIntegrityLevel with level frozen. That does two things, both of them local:
- Marks the object non-extensible, so no new properties can be added.
- Walks the object’s own property keys and sets each data property to
writable: false, configurable: false.
There is no recursion in that algorithm and no hook that would make it recurse. A property holding an object stores a reference; freezing makes the reference unchangeable, not the thing at the other end of it. config.db will always point at the same object — that object’s own fields were never touched.
The part that catches people out: Map, Set and Date
Map does not keep its entries in properties. It keeps them in an internal slot ([[MapData]]) that the property machinery cannot see, so making the object non-extensible and its own properties read-only locks nothing:
const m = Object.freeze(new Map([['a', 1]]));
m.set('b', 2);
m.delete('a');
console.log([...m.entries()], Object.isFrozen(m));
[ [ 'b', 2 ] ] true
The entry was added, the original entry was deleted, and Object.isFrozen still says true. Set behaves the same way via add, and so does Date:
const d = Object.freeze(new Date('2020-01-01T00:00:00Z'));
d.setTime(0);
console.log(d.toISOString()); // 1970-01-01T00:00:00.000Z
Arrays are the exception that behaves sensibly — a frozen array is non-extensible, so push throws:
TypeError: Cannot add property 1, object is not extensible
Strict mode decides whether you get told
The TypeError on a top-level write only happens in strict mode. ES modules and class bodies are always strict; a plain CommonJS file without 'use strict' is not, and there the failed write is discarded in silence:
// config.cjs — sloppy mode
const config = Object.freeze({ retries: 3 });
config.retries = 99;
console.log('retries =', config.retries); // retries = 3
Same on Node v26.2.0: no throw, and retries is still 3. The freeze worked; you just never found out that something tried to fight it. That is the shape that hides in a codebase for months — the write that “obviously happened” never did.
What to do
Freeze recursively when you actually mean immutable:
const deepFreeze = (obj) => {
for (const value of Object.values(obj)) {
if (value && typeof value === 'object') deepFreeze(value);
}
return Object.freeze(obj);
};
const cfg = deepFreeze({ db: { host: 'localhost' }, tags: ['a'] });
console.log(Object.isFrozen(cfg.db)); // true
cfg.db.host = 'evil.example';
console.log(cfg.db.host); // localhost
Three things to keep in mind when you use it:
| Concern | What to do |
|---|---|
| Cycles | The version above recurses forever on a.self = a. Track visited objects in a WeakSet if your data can contain cycles. |
Map / Set / Date | Cannot be frozen meaningfully. Convert to a plain object or array at the boundary, or wrap in a read-only facade that throws on mutation. |
| Getters | Object.values invokes them. If a getter is expensive or has side effects, iterate Object.getOwnPropertyNames and read descriptors instead. |
And two habits that avoid needing the freeze at all:
- Hand out copies, not the original.
structuredClone(config)returns a plain deep copy — note that the clone is not frozen, which is usually what you want for a working copy and is a nasty surprise if you assumed the property travelled with the data. - Let the type system carry it. In TypeScript,
as constandreadonlycost nothing at runtime and catch the mutation at the point someone writes it.Object.freezeis the runtime backstop for code the compiler never sees, not the primary defence.
Caveats
- Freezing is one-way. There is no
Object.unfreeze, so a frozen default object cannot later be patched in a test — build a fresh one instead. Object.isFrozenis as shallow asfreeze. It answers “is this object frozen”, never “is this tree frozen”, so it cannot be used to verify a deep freeze without walking the tree yourself.- A frozen object’s prototype is not frozen. Someone can still add behaviour on the prototype chain unless you freeze that too.
Object.freeze(null)and primitives return the argument unchanged rather than throwing — since ES2015 the function accepts non-objects silently, so a typo passingundefinedlooks like it worked.- This is not a security boundary. It stops accidents inside your own process; it does nothing about untrusted code that was already able to run.