Ch. 1 · JavaScript

JavaScript Object.freeze: Shallow vs Deep Freeze and Limits

What Object.freeze really locks, why it is shallow, how strict mode changes failures, and how to deep freeze config without surprises.

~7 min readadvancedupdated Oct 6, 2026

“Here is a config object we export with Object.freeze. A colleague changed config.db.host at runtime and it worked. Why?” Variants of this question come up when interviewers want to see whether you understand immutability in JavaScript as a mechanism rather than a slogan. Many candidates say “freeze makes an object immutable,” and the interviewer immediately follows up with a nested object, a Map, or a non-strict script to see whether the claim survives.

A good answer explains exactly which operations freezing blocks, where its protection ends, and when freezing is the right tool compared with copying or simply not exposing the object.

Before you start

You should know the difference between primitives and objects (values versus references), what const does, and roughly what strict mode is. Familiarity with property descriptors (writable, configurable) helps, since freezing is defined in terms of them. ES modules are always in strict mode, which matters for the examples.

The short answer

Object.freeze(obj) makes every own property of obj non-writable and non-configurable and prevents new properties from being added, then returns the same object. It is shallow: property values that are objects remain fully mutable unless you freeze them too. Writes to a frozen object are silently ignored in sloppy mode and throw a TypeError in strict mode. It also cannot lock the internal data of built-ins like Map, Set or Date, because their contents are not properties.

How it works

JavaScript has three levels of “locking,” each stronger than the last:

  • Object.preventExtensions(obj): no new properties; existing ones can change or be deleted.
  • Object.seal(obj): no new properties and no deletions (all properties become non-configurable); values can still change.
  • Object.freeze(obj): sealed, and every data property is non-writable.
'use strict';
const settings = Object.freeze({ retries: 3, db: { host: 'localhost' } });

console.log(Object.isFrozen(settings)); // true
console.log(Object.getOwnPropertyDescriptor(settings, 'retries'));
// { value: 3, writable: false, enumerable: true, configurable: false }

try {
  settings.retries = 10;
} catch (e) {
  console.log(e.constructor.name); // TypeError
}

settings.db.host = 'prod.internal';
console.log(settings.db.host); // prod.internal
console.log(Object.isFrozen(settings.db)); // false
JavaScript

The last two lines are the whole interview: settings.db holds a reference to another object, and freezing settings only stops you from replacing that reference. The nested object has its own properties and its own descriptors, untouched by the outer freeze.

Accessor properties (getters and setters) are made non-configurable but their functions still run, so a getter can return changing values from a frozen object, and a setter can write to some other, unfrozen state.

Step-by-step walkthrough

Step 1: See how sloppy mode hides failures

Outside strict mode, assigning to a non-writable property does nothing and reports nothing. This is why a frozen object can appear to “work” in an old script and then throw once the same code moves into an ES module, which is always strict.

const run = new Function(`
  const o = Object.freeze({ count: 1 });
  o.count = 2;
  o.extra = true;
  delete o.count;
  return JSON.stringify(o);
`);
console.log(run()); // {"count":1}
JavaScript

new Function bodies are sloppy by default, so all three changes are silently discarded. In a .mjs file the first assignment would throw.

Step 2: Write a deep freeze that handles cycles

To freeze a whole tree, walk every own property (including symbol keys) and freeze object values before or after freezing the parent. Track visited objects so a cyclic structure does not recurse forever.

function deepFreeze(value, seen = new WeakSet()) {
  if (value === null || typeof value !== 'object' && typeof value !== 'function') return value;
  if (seen.has(value)) return value;
  seen.add(value);
  for (const key of Reflect.ownKeys(value)) {
    const desc = Object.getOwnPropertyDescriptor(value, key);
    if ('value' in desc) deepFreeze(desc.value, seen);
  }
  return Object.freeze(value);
}

const config = { db: { host: 'localhost', ports: [5432] } };
config.self = config;
deepFreeze(config);
console.log(Object.isFrozen(config.db), Object.isFrozen(config.db.ports)); // true true
JavaScript

The descriptor check avoids calling getters during the walk, which could trigger side effects.

Step 3: Know what freeze cannot reach

Map, Set, Date, typed arrays and class instances with private fields keep their data in internal slots, not properties. Freezing the wrapper object does nothing to that data.

'use strict';
const roles = Object.freeze(new Map([['admin', true]]));
roles.set('guest', true);
console.log(roles.size); // 2

const when = Object.freeze(new Date(0));
when.setFullYear(2030);
console.log(when.getFullYear()); // 2030
JavaScript

Typed arrays with elements cannot be frozen at all; Object.freeze(new Uint8Array(4)) throws a TypeError. For these types, protection means not exposing the mutable instance: return a copy, or wrap it in an object that only exposes read methods.

Step 4: Separate const from freeze

const protects the binding: you cannot point the variable at a different value. Object.freeze protects the object’s properties. They are independent, and you often want both.

const a = { n: 1 };
a.n = 2;            // allowed: const does not freeze
let b = Object.freeze({ n: 1 });
b = { n: 99 };      // allowed: freeze does not stop rebinding
console.log(a.n, b.n); // 2 99
JavaScript

Worked scenario

A Node service exported a frozen defaults object. A request handler merged user preferences into it for one request, and because the nested limits object was shared and unfrozen, the change leaked into every later request.

const DEFAULTS = Object.freeze({ theme: 'light', limits: { pageSize: 20 } });

function handlerBroken(prefs) {
  const opts = { ...DEFAULTS };
  if (prefs.pageSize) opts.limits.pageSize = prefs.pageSize;
  return opts.limits.pageSize;
}
console.log(handlerBroken({ pageSize: 500 })); // 500
console.log(handlerBroken({})); // 500
console.log(DEFAULTS.limits.pageSize); // 500
JavaScript

The spread copied the top level, but opts.limits and DEFAULTS.limits are the same object. The fix has two parts: deep freeze the defaults so accidental writes throw in strict code, and build per-request options with new nested objects rather than editing shared ones.

'use strict';
const deepFreeze = (o) => {
  for (const v of Object.values(o)) if (v && typeof v === 'object') deepFreeze(v);
  return Object.freeze(o);
};
const DEFAULTS = deepFreeze({ theme: 'light', limits: { pageSize: 20 } });

function handler(prefs) {
  const opts = { ...DEFAULTS, limits: { ...DEFAULTS.limits, ...prefs } };
  return opts.limits.pageSize;
}
console.log(handler({ pageSize: 500 })); // 500
console.log(handler({})); // 20

try {
  DEFAULTS.limits.pageSize = 1;
} catch (e) {
  console.log('blocked:', e.constructor.name); // blocked: TypeError
}
JavaScript

Freezing turned a silent cross-request leak into a loud error at the line that caused it, which is the real value of freezing shared state.

Common mistake

  • “freeze makes the object deeply immutable.” It is shallow. Nested objects, arrays and built-in collections remain mutable.
  • “const objects cannot change.” const prevents reassignment only.
  • “Freezing is free, so freeze everything returned from every function.” It is cheap but not free, it complicates code that legitimately builds objects, and it can make some engine optimisations harder. Freeze shared, long-lived data such as config, constants and cached responses.
  • Expecting an error in sloppy mode. Writes vanish silently, which hides bugs. Run code as modules or under 'use strict'.
  • “A frozen object cannot be changed by its prototype.” Freezing an object does not freeze its prototype. Properties inherited from an unfrozen prototype can still change underneath it.

Verify the behavior

Use Object.isFrozen on every level and confirm that strict-mode writes throw.

import test from 'node:test';
import assert from 'node:assert/strict';

const deepFreeze = (o) => {
  for (const v of Object.values(o)) if (v && typeof v === 'object') deepFreeze(v);
  return Object.freeze(o);
};

test('shallow freeze leaves nested objects open', () => {
  const o = Object.freeze({ inner: { x: 1 } });
  assert.equal(Object.isFrozen(o.inner), false);
});

test('deep freeze locks every level and throws on write', () => {
  const o = deepFreeze({ inner: { list: [1, 2] } });
  assert.equal(Object.isFrozen(o.inner.list), true);
  assert.throws(() => { o.inner.list.push(3); }, TypeError);
});
JavaScript

Run node --test freeze.test.mjs; both tests pass. Note that push on a frozen array throws even in sloppy mode, because array methods perform their writes in strict fashion internally.

Follow-up questions

Can you unfreeze an object? No. Freezing is one-way because properties become non-configurable. Make a copy instead.

What is the difference between seal and freeze? Both block adding and deleting properties. Only freeze also blocks changing values of data properties.

Does freezing help performance? Rarely in a measurable way. Its value is correctness: catching unintended writes. Treat performance claims about it with suspicion.

How do libraries like Immer relate to freeze? Immer produces new structurally shared objects from “mutating” drafts, and in development auto-freezes results so accidental writes surface. Freezing is the guard; copying is the update strategy.

Interview exercise

What does this module print, and why? Then change one line so the final log prints 3.

const state = Object.freeze({ items: [1, 2], count: 2 });
state.items.push(3);
try {
  state.count = 3;
} catch (e) {
  console.log('error:', e.constructor.name);
}
console.log(state.items.length, state.count);
// error: TypeError
// 3 2
JavaScript

Answer and reasoning

Because the file runs as an ES module, it is strict. state.items.push(3) succeeds because the array is a separate object that was never frozen; only the reference stored in items is locked. state.count = 3 targets a non-writable property, so strict mode throws a TypeError, which the catch logs. The final line prints 3 2.

To print 3 for the count, do not mutate the frozen object; create a new one: const next = { ...state, count: state.items.length }; and log next.count. That reflects the real pattern behind immutable state: freeze to detect accidental writes, and produce new objects for intentional changes. If the interviewer wanted the push to fail too, freeze items as well (or deep freeze state), and then push throws a TypeError.

Continue learning

Explore more JavaScript interview questions and test yourself with the JavaScript MCQs. Related notes: property descriptors and accessors, shallow versus deep copies and structuredClone and transfer. Official reference: Object.freeze on MDN.

More in JavaScript

read ✓JavaScript · easy

JavaScript Array Copies and Shared Objects

Why [...arr] and slice() copy the array but not the objects inside it, and how to choose a targeted update, structuredClone or JSON safely.

~7 min readread →
esc