A team freezes its shared configuration and lookup objects at startup so that nothing can mutate them at run time. Which parts of that guarantee actually hold, and where does Object.freeze() quietly fail to deliver it?
answer
- own properties only, one level
- internal slots are not properties
- setters still run when frozen
- silent in sloppy, loud in strict
- detection tool, not a boundary
basics
~20 sFreezing reliably locks an object's own data properties and prevents new ones or prototype replacement. It does not reach nested objects, Map/Set/Date internals, #private fields, closure state, or accessor setters — and in sloppy-mode code violations fail silently instead of throwing.
solid answer
~50 sWhat genuinely holds: the object's own data properties cannot be written, deleted or reconfigured, no properties can be added, and the prototype link cannot be replaced. Everything else is a gap. Freeze is shallow, so any nested object or array in the tree is fully mutable. State that lives in internal slots rather than properties is untouched — `map.set(...)`, `set.add(...)` and `date.setHours(...)` all still work on a frozen instance, as does a method writing a `#private` field. Accessor properties keep their setters, so `obj.x = 1` can still run arbitrary code. And enforcement depends on the *writer*: a violation from sloppy-mode code is silently discarded rather than thrown, so a bug can hide indefinitely. I treat freeze as a strong bug detector in an all-ESM codebase, not as a security boundary or a substitute for not sharing mutable state.
code
javascript · 11 linesclass Counter { #n = 0; bump() { return ++this.#n; } }
const c = Object.freeze(new Counter());
console.log(c.bump(), c.bump()); // 1 2 - private field is an internal slot
const m = Object.freeze(new Map([['a', 1]]));
m.set('b', 2);
console.log(m.size); // 2 - Map entries are not properties
const d = Object.freeze(new Date(0));
d.setFullYear(1999);
console.log(d.getFullYear()); // 1999 - Date state is not a propertygo deeper
Know the honest scope: freeze locks one object's own properties and nothing deeper. If asked whether a frozen config is safe, say the nested objects are not.
Enumerate the gaps precisely — shallowness, internal-slot state in Map/Set/Date, private fields, accessors that still run — and explain why each one escapes a property-level operation.
Turn it into a decision: where in the system freezing pays off, how you verify it in tests, and how strict versus sloppy code changes whether violations are visible at all. Be able to diagnose a 'my frozen config changed' report.
Own the guarantee story across the codebase: what immutability the team is actually promising, whether it is enforced at runtime, by convention, or by types, and what the traversal costs when applied to per-request data.
## Start with what is actually guaranteed After `Object.freeze(obj)`: - every own **data** property is `writable: false` and `configurable: false` — its value cannot change, it cannot be deleted, and its descriptor cannot be rewritten; - the object is non-extensible — no new own property, by assignment or by `Object.defineProperty`; - `Object.setPrototypeOf(obj, x)` throws a `TypeError`, and so does assigning through `__proto__`, in both strict and sloppy code. Those properties are real and permanent: there is no `unfreeze`. If the whole state you care about is scalar own properties on one object, freezing delivers what it promises. ## Gap 1 — depth Freeze never recurses. A configuration tree is exactly the shape where this bites: ```js export const config = Object.freeze({ db: { host: 'localhost', pool: { max: 10 } }, features: ['a', 'b'], }); config.db.pool.max = 500; // succeeds config.features.push('c'); // succeeds ``` The top-level references are locked; every object behind them is open. Either deep-freeze recursively or keep the config flat. ## Gap 2 — state that is not a property Freezing acts on properties. Several built-ins store their data in internal slots, so freezing them accomplishes nothing meaningful: ```js const m = Object.freeze(new Map([['a', 1]])); m.set('b', 2); m.size; // 2 — freezing a Map does not lock its entries const d = Object.freeze(new Date()); d.setFullYear(1999); // mutates fine ``` `Set`, `WeakMap`, typed arrays and `ArrayBuffer`-backed views behave the same way. If a lookup table must be immutable, a frozen plain object or frozen array of frozen entries is the honest representation; a frozen `Map` is decoration. Class instances have the same issue on a different axis: `#private` fields are internal slots, not properties, so a method on a frozen instance can keep mutating them. So can any closure variable a method captured. ```js class Counter { #n = 0; bump() { return ++this.#n; } } const c = Object.freeze(new Counter()); c.bump(); // 1 — freeze did not touch the private field ``` ## Gap 3 — accessors Freeze cannot remove a setter; it only makes the accessor property non-configurable. `writable` does not apply to accessors at all. So a frozen object with `set value(v) { store.push(v) }` still executes that setter on assignment and still mutates `store`. If "frozen" is being sold as "assignment cannot have effects", accessors break it. ## Gap 4 — how violations are reported A failed write throws only in strict-mode code. In an all-ESM or all-class codebase that is every file, and freeze becomes an excellent early-warning system: the bug surfaces at the offending line with a stack trace. In a codebase that still ships classic `<script>` files or sloppy CommonJS, the same write is silently discarded — the value simply does not change, and someone spends an afternoon on "why is my setting ignored". Knowing which regime you are in is the difference between freeze being a diagnostic and freeze being a trap. ## Gap 5 — what freeze is not - **Not a security boundary.** It stops accidental mutation, not a determined caller who can still read everything, call accessors, mutate nested objects, or simply hold their own copy. Confidentiality needs closures, `#private` fields or a different process — not freeze. - **Not aliasing protection.** Freezing after handing the object to three call sites locks the object, but any object those call sites captured *from inside it* before freezing is still shared and mutable. - **Not preserved by copying.** `structuredClone(frozenObj)` and `{ ...frozenObj }` both yield ordinary mutable objects. Freezing a source does not make derived data safe. - **Not a prototype lock.** Freezing an instance does not freeze its prototype; shared methods on an unfrozen prototype can be replaced by anyone. ## How to use it well A reasonable production posture: 1. Deep-freeze module-level constants, enum-like tables and startup config — the cost is paid once and the payoff is catching an entire class of bug. 2. Keep those structures out of `Map`/`Set`/`Date` if immutability is the point, or wrap them behind a read-only accessor API. 3. Rely on freeze for *detection* rather than *protection*: strict-mode throws point at the exact offender. 4. For hot-path per-request data, prefer a copy-on-write convention over freezing every object, and consider freezing only under a development flag. 5. Verify with `Object.isFrozen` in tests instead of asserting a value did not change — the latter passes for the wrong reason in sloppy mode. The honest one-sentence summary for an interviewer: freeze locks own properties of one object and nothing else, so it is a discipline enforcement tool whose blast radius you have to know precisely.
- If freeze is not a security boundary, what do you use when a value genuinely must be unreachable by callers?Keep it out of the object graph entirely: a closure variable in a factory function, a `#private` class field, or a `WeakMap` keyed by the instance. Those are the only mechanisms where the data is not reachable through any property, so no amount of reflection — `Object.getOwnPropertyNames`, `Reflect.ownKeys`, a `Proxy` — exposes it. Freeze only makes reachable data read-only, and it still leaves it fully readable.
- How would you make a lookup table both immutable and convenient, given that a frozen Map is useless?Use a frozen plain object (or a frozen array of frozen records) and freeze it deeply, so the entries themselves cannot change. If Map ergonomics matter — non-string keys, size, iteration order guarantees — build the Map at startup and expose only a read function that closes over it, never the Map itself. That way the mutation surface is the API you wrote, not `set` and `delete`.
- Your service deep-freezes every incoming request payload and profiling shows it in the hot path. What do you change?Freeze at boundaries rather than universally: freeze the long-lived shared structures at startup, and for per-request data rely on a copy-on-write convention enforced by review and tests. If the freezing exists to catch bugs, gate it behind a development flag so production skips the traversal entirely — the guarantee it bought was detection, and detection belongs where you are still finding bugs.
saying these in an interview costs you the question
- Calling Object.freeze a security or access-control mechanism
- Assuming a frozen Map or Date cannot change
- Believing freeze protects a class's private fields
- Expecting a spread copy of a frozen object to be frozen
- Thinking a frozen instance implies a frozen prototype