Skip to content
← Journal
11 min readAta Mohammadi

Senior Frontend Interview Prep, Part 4: Scope, Closures and the Temporal Dead Zone

Fifteen questions on execution contexts and environment records: what the TDZ actually is, why `let` in a loop works, how `bind` is implemented, and why one `eval` deoptimises every identifier around it.

Scope questions have a reputation for being junior. They are not — the junior version asks what a closure is, and the senior version asks what the engine allocates, where it allocates it, and what it costs. Most of the memory leaks in part two of this series are scope questions wearing a different hat.

Execution contexts and environments

46. What is an execution context, and how do LexicalEnvironment and VariableEnvironment differ?

An execution context is the specification's bookkeeping for one running piece of code. It holds:

  • Code evaluation state — where execution is, so it can be suspended and resumed (which is exactly how generators and await work).
  • Function — the function object being evaluated, if any.
  • Realm — the set of intrinsics: this realm's Array, Object, Promise. Two realms (an iframe, a worker, a vm context) have different ones, which is why arr instanceof Array can be false for a genuine array from another frame.
  • LexicalEnvironment — resolves identifiers for let, const, class and block-scoped function declarations.
  • VariableEnvironment — resolves var and function-scoped declarations.

The split is the whole answer to "why do var and let behave differently?". Entering a block swaps in a new LexicalEnvironment and leaves VariableEnvironment untouched, so block-scoped declarations come and go while var declarations persist for the whole function.

47. What is the temporal dead zone, mechanically?

When a scope is entered, the engine creates bindings for everything declared in it — before running any of it.

  • A var binding is created and initialised to undefined.
  • A let / const / class binding is created and left uninitialised, a distinct state from "holds undefined".

The TDZ is the span between entering the scope and evaluating the declaration statement. Reading an uninitialised binding throws.

{
  // `token` is bound here, but uninitialised. The TDZ starts now.
  const read = () => token; // legal: closing over a binding, not reading it
  // console.log(token);    // ReferenceError: Cannot access 'token' before initialization
  let token = "auth_xyz";   // initialised. TDZ ends.
  console.log(read());      // "auth_xyz"
}

The distinction the question is really probing: the TDZ is temporal, not spatial. read is defined above the declaration and works fine, because what matters is when it runs, not where it was written.

48. Why can you redeclare var but not let?

Because the check happens in different records. When a block's lexical declarations are instantiated, the spec requires that adding a let, const or class name that already exists in that same LexicalEnvironment is an early error — a SyntaxError raised at parse time, before a single statement runs.

var goes into the VariableEnvironment, where a duplicate declaration is defined to be a no-op: the existing binding is kept and the redeclaration does nothing. It is not that var is more permissive by accident; it is that the spec explicitly makes the second declaration inert, because a decade of code depended on it.

49. What is the scope chain, and when is [[OuterEnv]] fixed?

Every environment record has an [[OuterEnv]] pointer. Identifier resolution walks that chain outward until it finds a binding or reaches the global record.

The crucial word is lexical: [[OuterEnv]] is decided by where the function was written, not where it is called. When a function object is created, its [[Environment]] slot captures the currently running context's LexicalEnvironment. Calling it later creates a fresh context whose [[OuterEnv]] is that captured slot — no matter what the call stack looks like at that moment.

That single design choice is why closures work at all, and why this (which is not resolved this way for ordinary functions) is the exception that causes so much confusion.

50. How does a closure survive after its outer function returns?

The execution context is popped from the call stack. The environment record is not on the stack — it is a heap object.

An inner function's [[Environment]] slot holds a strong reference to that record. As long as the inner function is reachable, the record is reachable, and the collector cannot touch it.

Which is the exact mechanism behind the closure leak from part two of this series: the record is one object shared by every sibling closure in that scope, so retaining the smallest closure retains everything the record holds.

51. Declarative versus object environment records

  • Declarative — binds names directly. Engines implement these as flat arrays or contexts where each variable is a fixed slot, so a lookup compiles to an indexed load. This is what almost all of your code uses.
  • Object — binds names to the properties of a real object. The global environment uses one (which is why var x = 1 at global scope creates window.x), and so did the with statement. Every read is a full property lookup with prototype-chain traversal.

Say the consequence out loud: the object environment record is why the global scope is the slowest place to put a variable, and why module scope is faster than a script's top level even before you consider bundling.

52. How does this resolve, and why are arrow functions different?

For an ordinary function, this is decided at the call site:

Call form this
obj.method() obj
fn() undefined in strict mode / modules; the global object otherwise
new Fn() the newly created instance
fn.call(x) / fn.apply(x) / fn.bind(x)() x

An arrow function has no this binding at all. this inside one is an ordinary identifier, resolved up the [[OuterEnv]] chain to the nearest enclosing non-arrow function — the same lookup as any other free variable.

This is why an arrow function can never be a method that needs its receiver, why .bind() on an arrow does nothing, and why the class-field arrow (onClick = () => …) works: the field initialiser runs with this set to the instance, so the arrow captures it.

53. What does bind() actually create?

A bound function exotic object — not a wrapper you could write yourself in userland, though you can get close. It carries three internal slots:

  • [[BoundTargetFunction]] — the original function.
  • [[BoundThis]] — the receiver you supplied.
  • [[BoundArguments]] — the leading arguments you supplied.

Calling it prepends [[BoundArguments]] to the new arguments and invokes the target with [[BoundThis]]. Two details are worth having:

function Point(x, y) {
  this.x = x;
  this.y = y;
}
const Bound = Point.bind({ ignored: true }, 1);

const p = new Bound(2); // `new` ignores [[BoundThis]] entirely
console.log(p.x, p.y);  // 1 2
console.log(p instanceof Point); // true

new on a bound function ignores the bound this and constructs against the target's prototype. And bind returns a new function object every time — which is why onChange={this.handle.bind(this)} in a render path defeats every memoisation you have, and why it is a useCallback-shaped bug that predates hooks.

54. Why do eval() and with destroy scope optimisation?

Normally the parser can see every declaration in a scope and assign each variable a fixed slot, so an identifier read becomes an indexed load and unused bindings can be dropped entirely.

A direct eval() call, or a with block, makes the set of bindings unknowable at parse time: the evaluated string could introduce a new x that shadows an outer one. The engine has to fall back to dynamic lookup along the scope chain for every identifier in the affected scope, and it can no longer prove anything is dead.

The escape hatch worth knowing: indirect eval — (0, eval)(src) or globalThis.eval(src) — always runs in global scope and therefore cannot touch the local one, so it does not poison the enclosing function. If you truly need to evaluate a string, that form is the one that does not cost you the scope around it. (new Function(...) behaves the same way.)

55. Why does typeof protect undeclared variables but not the TDZ?

console.log(typeof neverDeclared); // "undefined" — no throw
console.log(typeof later);         // ReferenceError
let later = 1;
typeof in TDZ: ReferenceError - Cannot access 'later' before initialization

For a genuinely undeclared identifier, resolution fails to find any binding, and typeof has a special case that turns that failure into "undefined" — the historical safety net for feature detection.

For a let in its TDZ, the binding exists; it is merely uninitialised. The spec requires that reading an uninitialised binding throws, and typeof gets no exemption because there is nothing to be lenient about: the name is there.

So typeof is safe against misspelling and unsafe against ordering, which is the opposite of what most people assume.

56. for (var i…) versus for (let i…) with closures

const a = [];
for (var i = 0; i < 3; i++) a.push(() => i);
console.log("var:", a.map((f) => f()).join(","));

const b = [];
for (let j = 0; j < 3; j++) b.push(() => j);
console.log("let:", b.map((f) => f()).join(","));
var: 3,3,3
let: 0,1,2

var creates one binding in the enclosing function's VariableEnvironment. All three closures capture the same slot, and by the time they run, the loop has finished and the slot holds 3.

let in a for head is special-cased in the specification: a new environment record is created per iteration, and the value is copied forward into it before the update expression runs. Three iterations, three records, three independent bindings.

This is not a closure quirk; it is the loop construct being defined to copy. It is also why the pre-ES6 workaround was an IIFE — you had to manufacture the per-iteration scope yourself.

57. What is shadowing, and how is it resolved?

An inner declaration with the same name as an outer one. Resolution starts at the innermost record and stops at the first match, so the outer binding is intact but unreachable by name from inside.

The senior framing is that shadowing is a tool, not an accident: shadowing a mutable outer value with a narrowed const inside a block is how you stop a later refactor from reassigning it. Where it bites is var shadowing in a nested function, where the hoisting makes the shadow start at the top of the function rather than at the declaration.

58. How do ES modules differ from classic scripts?

  • Always strict. No "use strict" needed, and no way to opt out.
  • Module scope. Top-level var creates a module binding, not a property on window.
  • this is undefined at the top level, not the global object.
  • Deferred by default. A <script type="module"> behaves like defer: fetched in parallel, executed after parsing, in order.
  • Static structure. Imports and exports are analysed before evaluation, which is what makes live bindings, cycles and tree-shaking possible.

And the one that reaches beyond trivia: module evaluation is itself a promise job, which is why — as shown in part three of this series — process.nextTick loses to Promise.then at the top level of an ESM file in Node and wins everywhere else.

59. Declarations, expressions, and named function expressions

  • Declaration function foo() {} — name and body hoisted. Callable before its line.
  • Expression var foo = function () {}var foo is hoisted and initialised to undefined; the assignment happens when execution reaches it. Calling early throws TypeError: foo is not a function, not a ReferenceError. The difference in error type is itself an interview question.
  • Named function expression const foo = function bar() {}bar is bound only inside its own body, for self-reference. It does not exist outside.

That inner binding is also immutable:

const f = function self() {
  try {
    self = 1;
  } catch (error) {
    return `${error.constructor.name}: ${error.message}`;
  }
  return "assigned";
};

console.log(f());
console.log("outside:", typeof self);
TypeError: Assignment to constant variable.
outside: undefined

An immutable binding, visible only inside, created by a syntax most people think is only for stack traces.

60. What did IIFEs solve, and are they obsolete?

Before ES6 there were exactly two scopes: global and function. Anything you wanted to keep private had to live inside a function, so the idiom was to make one and call it immediately:

var Counter = (function () {
  var count = 0; // private: nothing outside can reach it
  return {
    increment: function () {
      return ++count;
    },
  };
})();

Block scoping and modules replaced both jobs: { … } with let/const gives you a temporary scope, and a module gives you a private namespace with a declared public surface.

But "largely obsolete" is more honest than "obsolete". IIFEs are still the standard way to get a top-level await into a non-module script, they are what bundlers emit to isolate chunks, and — as part one of this series covered — wrapping a function in parentheses is a hint that makes V8 parse it eagerly instead of pre-parsing then re-parsing. They stopped being an application-code idiom and became a tooling one.

What this section is really testing

Every question above is one question in disguise: do you know that scope is a data structure the engine builds, not a rule the language enforces?

Once you hold that, the answers stop being separate facts. The TDZ is a binding state. Closure retention is a heap reference. eval is a loss of static knowledge. let in a loop is a copy per iteration. Same object, different angles.


Next: the object model — prototype chains, property descriptors, Proxy and Reflect, and what class compiles down to.

Next step

Tell us what is broken or what should exist.

Send the shape of the problem and any constraints you already know — budget, deadline, the stack you are stuck with. You will get a written reply from the engineer who would do the work, not a sales sequence.