FE-10 — JavaScript Runtime Internals
Execution Contexts, the Scope Chain, Closures, the Event Loop, and Everything Underneath
Part of The Production Frontend Engineer series. Prerequisites: FE-00 (browser internals, main thread, event loop overview).
Opening
After this chapter, JavaScript will never surprise you again. Not a closure gotcha, not a this binding that seems wrong, not a Promise that resolves in an unexpected order, not an async/await that behaves differently than you expected. These are not quirks. They are not things to memorise. They are the completely predictable output of a runtime that works in a specific, physical way — and once you see the machine, every behaviour becomes obvious. Right now you write JavaScript the way most engineers do: you know what works and you avoid what doesn't, based on accumulated experience. After this chapter you will know why everything works, which means you can reason about code you have never seen before, diagnose bugs you have never encountered, and answer any JavaScript internals question by derivation rather than recall. That is the difference between a good engineer and a dangerous one.
Part 1: The Execution Context — What Actually Happens When a Function Is Called
Before closures, before this, before the event loop — you need to understand what the JavaScript engine does the moment a function is called. Everything else in this chapter is built on this foundation.
When V8 encounters a function call, it creates a data structure called an execution context. Think of it as a self-contained package of everything needed to execute that function: where the variables live, what this refers to, and a link back to the context that created this one. V8 pushes this package onto a stack — the call stack — and begins executing the function's code. When the function returns, V8 pops the context off the stack and resumes wherever it left off.
The call stack is a literal stack data structure in V8's memory. Last in, first out. When main() calls getUserData() which calls parseJSON(), the stack has three frames:
┌─────────────────────────┐ ← top of stack (currently executing)
│ parseJSON() │
├─────────────────────────┤
│ getUserData() │
├─────────────────────────┤
│ main() │
└─────────────────────────┘ ← bottom of stack
parseJSON executes, returns, its frame is popped. getUserData resumes, returns, its frame is popped. main resumes. This is the mechanical reality behind every function call you have ever written.
Each execution context contains three things that matter deeply:
The Variable Environment — a record (a plain object in memory) where all the variables declared in this function live. When you write const name = 'Alice' inside a function, V8 creates an entry in this record: { name: 'Alice' }. When you read name later, V8 looks up this record.
The this binding — a reference to whatever object this points to inside this function. How this reference is determined is covered entirely in Part 4. For now: it is a field in the execution context, set at call time, not at definition time.
The Outer Environment Reference — a pointer to the variable environment of the context that lexically surrounds this function in the source code. Not the function that called this function — the function (or module, or global scope) that contains this function's definition in the written code. This pointer is the scope chain, and it is the physical mechanism behind closures.
This three-part structure — variables, this, outer reference — is everything. Every JavaScript behaviour you will learn in this chapter is an operation on one of these three fields.
The Global Execution Context
Before any function runs, V8 creates one execution context that is always at the bottom of the stack: the global execution context. Its variable environment holds all globally declared variables (var declarations at the top level, function declarations). Its this binding is window in a browser or global in Node.js. Its outer environment reference is null — it has no outer scope, it is the outermost scope.
When your JavaScript file loads, V8 creates the global context, pushes it onto the stack, and begins executing your top-level code. It never leaves the stack until the page closes or the Node process exits.
Part 2: Variable Declaration, Hoisting, and the Temporal Dead Zone
You have encountered hoisting before — variables and function declarations being "moved to the top." The actual mechanism is more precise and more interesting than that description suggests, and understanding it prevents an entire class of bugs.
When V8 creates an execution context, before executing a single line of code, it does a preparatory pass through the function's source code. In this pass:
var declarations are registered in the variable environment immediately, with the value undefined. The assignment (if any) stays where it is in the code. So when the function starts executing, every var variable already exists in the variable environment — it just has no meaningful value yet. This is hoisting.
function declarations (not expressions — declarations written as function foo() {}) are fully hoisted: the function object is created and stored in the variable environment during the preparatory pass. This is why you can call a function before its definition in the source file and it works.
let and const declarations are also registered during the preparatory pass, but they are not initialised. The variable name is noted as existing in this scope, but any attempt to read it before the actual let or const line in the code throws a ReferenceError. This uninitialized-but-registered state is what the spec calls the Temporal Dead Zone (TDZ). The variable exists in the variable environment (V8 knows about it) but is deliberately inaccessible until the initialisation point is reached during execution.
console.log(x) // undefined — var is hoisted and initialised to undefined
var x = 5
console.log(y) // ReferenceError — y is in TDZ, registered but not initialised
let y = 5
foo() // works — function declaration is fully hoisted
function foo() { console.log('foo') }
bar() // TypeError: bar is not a function — only the var declaration is hoisted, not the assignment
var bar = function() { console.log('bar') }
The TDZ is not a quirk to memorise. It is a deliberate design decision: let and const were designed to have block scope and to force you to declare before use. The TDZ is the mechanism that enforces this. V8 knows the variable exists (to prevent you from accidentally accessing an outer scope's variable of the same name) but blocks access until the declaration line is reached.
Block Scope vs Function Scope
var is function-scoped: a var declared anywhere inside a function lives in that function's variable environment, regardless of which block (if, for, while) it was declared in.
let and const are block-scoped: each block (any pair of {}) creates a new scope, and a let/const declared inside a block lives only in that block's scope.
function example() {
if (true) {
var x = 1 // lives in example's variable environment
let y = 2 // lives in the if-block's scope
}
console.log(x) // 1 — var leaks out of the block
console.log(y) // ReferenceError — let is scoped to the block
}
The physical reality: V8 creates a separate variable environment for each block when using let/const. Each block scope has its own outer reference pointing to the enclosing scope. The scope chain traversal (Part 3) handles the lookup correctly.
Part 3: The Scope Chain — How Variable Lookup Actually Works
When your code references a variable, V8 does not search the entire program for it. It follows a precise, predictable chain of lookups that is determined entirely by where the code is written in the source file — not where it is called from or how it got there.
Here is the lookup algorithm: V8 looks in the current execution context's variable environment first. If the variable is there, done. If not, V8 follows the outer environment reference to the enclosing scope, looks there. If not found, follows the next outer reference. Continues until it either finds the variable or reaches the global context (outer reference is null). If not found in global, throws a ReferenceError.
This traversal is the scope chain. It is a chain of variable environments linked by outer references, rooted at the global context.
The critical property: the outer reference of each function is determined at the time the function is defined, not when it is called. This is called lexical scoping — the scope is determined by the lexical (source code) position of the function, not by the dynamic call graph.
const globalVar = 'I am global'
function outer() {
const outerVar = 'I am outer'
function inner() {
const innerVar = 'I am inner'
console.log(innerVar) // found in inner's own environment
console.log(outerVar) // not in inner's env → follow outer ref → found in outer's env
console.log(globalVar) // not in inner's or outer's → follow to global → found
}
inner()
}
outer()
The scope chain for inner:
inner's variable env { innerVar }
↓ (outer reference)
outer's variable env { outerVar }
↓ (outer reference)
global variable env { globalVar, outer }
↓ (outer reference)
null — end of chain
This chain is fixed when inner is defined (when V8 parses the source code and creates the function object). If you passed inner to a completely different part of the program and called it there, the scope chain would be identical — it always traces back through outer and then to global. It has nothing to do with where inner is called from.
This is the single physical fact that makes closures possible and makes the famous var-in-a-loop bug happen. Both are consequences of the same mechanism.
Part 4: Closures — What the Heap Object Actually Looks Like
A closure is not a language feature you opt into. Every function in JavaScript is a closure. The question is only whether the closed-over variables are interesting enough to matter.
When V8 creates a function object (when it processes the function definition), it attaches to that function object a reference to the current scope — the variable environment of the context in which the function was defined. This attached reference is the closure. The function carries its scope chain with it wherever it goes.
The consequence: when a function is returned from another function, or stored in a variable, or passed as a callback — the outer function's execution context may be gone from the call stack. But the variable environment that the inner function closes over is not garbage collected, because the inner function's closure reference is keeping it alive. V8 sees: "this heap object (the outer function's variable env) is still reachable from the inner function object, which is still reachable from this callback. Cannot collect."
function makeCounter() {
let count = 0 // lives in makeCounter's variable environment
return function increment() {
count++ // follows scope chain to makeCounter's env, finds count, increments it
return count
}
}
const counter = makeCounter()
// makeCounter's execution context is gone from the call stack
// but its variable environment { count: 0 } is still in heap memory
// because counter (the returned function) holds a closure reference to it
counter() // 1
counter() // 2
counter() // 3
// each call follows the scope chain to the same { count } variable env
// count persists between calls because the heap object persists
In memory, after makeCounter() returns, the heap looks like this:
counter (function object)
└── [[Scope]] reference
└── makeCounter's variable env { count: 3 } ← in heap, not collected
└── outer ref → global env
The [[Scope]] is V8's internal name for the closure reference. It is a real field on every function object in V8's heap.
The var-in-a-Loop Bug — Derived, Not Memorised
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0)
}
// Outputs: 3, 3, 3 — not 0, 1, 2
Most engineers know the output is 3, 3, 3 and know the fix is let. Few can explain why from first principles. Here it is:
var is function-scoped. There is no block scope for var. So all three iterations of the loop share a single i variable in the enclosing function's variable environment. Not three variables named i — one variable.
Each callback function's [[Scope]] points to the same variable environment, which contains the same i. When the callbacks eventually execute (after the loop has finished), they all follow their scope chain, find the same i, and read its current value — which is 3 (the value after the loop terminated).
With let:
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0)
}
// Outputs: 0, 1, 2
let is block-scoped. Each iteration of the loop creates a new block scope, a new variable environment, with its own i initialised to the current iteration's value. Each callback's [[Scope]] points to a different variable environment — iteration 0's env with i = 0, iteration 1's env with i = 1, iteration 2's env with i = 2. Three separate heap objects, three separate values. When the callbacks execute, each finds its own i.
You did not memorise this. You derived it from: var is function-scoped (one env for all iterations), let is block-scoped (one env per iteration), closures capture the env not the value.
Part 5: this — Four Rules, One Physical Principle
this confuses engineers because they think of it as pointing to "the object the function belongs to." This is wrong and it is why this seems to behave randomly. The correct mental model: this is determined by how the function is called, not where it is defined.
More precisely: when V8 creates an execution context for a function call, it sets the this binding by inspecting the call site. Four rules determine which object gets bound, applied in order of precedence.
Rule 1: New Binding (highest precedence)
If a function is called with new, this is a brand new object created by V8 for this call, and the function's prototype is set on that object.
function Person(name) {
this.name = name // this = the new object being constructed
}
const alice = new Person('Alice')
// V8: create new empty object, set Person.prototype on it,
// call Person with this = that new object, return it
alice.name // 'Alice'
Rule 2: Explicit Binding
If a function is called with .call(obj), .apply(obj), or .bind(obj), this is explicitly set to obj.
function greet() {
console.log(this.name)
}
const user = { name: 'Alice' }
greet.call(user) // 'Alice' — this is explicitly user
.bind() returns a new function with this permanently set. The new function's execution context will always have this = obj regardless of how it is later called. This is why you see this.handleClick = this.handleClick.bind(this) in older React class components — it ensures the event handler always has this pointing to the component instance, not whatever called it.
Rule 3: Implicit Binding
If a function is called as a method of an object (obj.method()), this is the object to the left of the dot.
const user = {
name: 'Alice',
greet() {
console.log(this.name) // this = user (the object to the left of the dot)
}
}
user.greet() // 'Alice'
// The trap:
const fn = user.greet // extract the function
fn() // this is now undefined (strict mode) or global (sloppy)
// because fn() has no object to the left of the dot
This is why callback patterns lose this. When you pass user.greet as a callback to setTimeout, the function is called without an object context — the implicit binding is lost.
Rule 4: Default Binding (lowest precedence)
If none of the above apply — plain function call with no new, no .call(), no object dot — this is undefined in strict mode, or the global object (window/global) in sloppy mode. Strict mode is the default in ES modules and in class bodies.
Arrow Functions — Lexical this
Arrow functions are not subject to any of the four rules. They do not have their own this binding at all. When an arrow function references this, it follows the scope chain to find this in the enclosing lexical context — the same way it would look up any other variable.
class Timer {
constructor() {
this.seconds = 0
}
start() {
// Regular function: this would be lost (default binding in the callback)
// Arrow function: this is the Timer instance from the enclosing start() context
setInterval(() => {
this.seconds++ // follows scope chain to start()'s this binding = Timer instance
}, 1000)
}
}
The arrow function's [[Scope]] contains the this binding from start()'s execution context — the Timer instance. It never gets its own this. This is not magic. It is the scope chain lookup applied to this as if it were a regular variable.
Part 6: The Prototype Chain — Property Lookup in Memory
JavaScript objects do not have classes in the traditional sense. Every object has an internal reference to another object called its prototype. When you access a property on an object, V8 looks for it on the object itself first. If not found, it follows the prototype reference to the prototype object and looks there. If not found there, follows that object's prototype. Continues until it either finds the property or reaches null (the end of the prototype chain).
const animal = {
breathes: true,
describe() {
return `I breathe: ${this.breathes}`
}
}
const dog = {
barks: true,
__proto__: animal // dog's prototype is animal
}
dog.barks // found on dog itself → true
dog.breathes // not on dog → follow prototype → found on animal → true
dog.describe() // not on dog → follow prototype → found on animal
// called with this = dog (implicit binding rule)
// → 'I breathe: true'
In memory:
dog object { barks: true }
↓ [[Prototype]] reference
animal object { breathes: true, describe: [function] }
↓ [[Prototype]] reference
Object.prototype { toString, hasOwnProperty, ... }
↓ [[Prototype]] reference
null — end of chain
class syntax in JavaScript is syntactic sugar over this prototype system. When you write:
class Animal {
constructor(name) { this.name = name }
speak() { return `${this.name} makes a sound` }
}
class Dog extends Animal {
speak() { return `${this.name} barks` }
}
const rex = new Dog('Rex')
What V8 actually creates:
rex is a plain object with { name: 'Rex' }. Its [[Prototype]] is Dog.prototype. Dog.prototype is an object with { speak: [Dog's speak function] }. Its [[Prototype]] is Animal.prototype. Animal.prototype has { speak: [Animal's speak function] }. Its [[Prototype]] is Object.prototype.
When you call rex.speak(), V8 looks on rex — no speak. Follows prototype to Dog.prototype — found. Calls it with this = rex.
instanceof checks the prototype chain: rex instanceof Dog asks "is Dog.prototype anywhere in rex's prototype chain?" Yes. rex instanceof Animal — also yes. rex instanceof Object — also yes.
Why This Matters Beyond Class Syntax
Understanding the prototype chain explains behaviour that classes hide. Property assignment always goes on the instance, never on the prototype, unless you explicitly set it. Prototype properties are shared across all instances — if you mutate a prototype property (an array or object), all instances see the mutation.
class Team {
members = [] // this is an instance property (set in the constructor by class fields)
}
// If you wrote it as a prototype property (older pattern):
function Team() {}
Team.prototype.members = [] // shared across all instances!
const a = new Team()
const b = new Team()
a.members.push('Alice') // mutates the shared prototype array
b.members // ['Alice'] — b sees the mutation
Part 7: The Event Loop — Full Precision
You saw the event loop in FE-00 as an overview. You need it now at full precision because interview questions about Promise ordering, setTimeout vs queueMicrotask, and async/await execution order all require knowing the exact algorithm — not an approximation of it.
The event loop is a continuous process running in V8. On each iteration, it executes one "task" and then processes all queued microtasks before rendering and picking the next task. The full algorithm:
Event Loop Iteration:
─────────────────────
1. Dequeue one task from the Task Queue and execute it
(the call stack must be empty before this step)
2. Process ALL microtasks:
a. Dequeue one microtask and execute it
b. If that microtask queued more microtasks, execute those too
c. Repeat until the Microtask Queue is completely empty
3. If a rendering update is needed (16.67ms has passed):
a. Run requestAnimationFrame callbacks
b. Run style calculation, layout, paint, composite
4. Go to step 1
Two queues. Different rules. Understanding both is the entire key to predicting JavaScript's async behaviour.
The Task Queue (Macrotask Queue)
Each iteration of the event loop processes exactly one task. Tasks come from:
setTimeout(fn, delay)— schedules a task after at leastdelaymssetInterval(fn, delay)— schedules recurring tasks- I/O callbacks (network responses, file reads in Node.js)
- User interaction events (click, keypress, scroll)
MessageChannelpostMessage<script>tag execution — the initial script evaluation is itself a task
The "at least" in setTimeout is important. setTimeout(fn, 0) does not run immediately. It schedules a task on the Task Queue. The task will not execute until: the current task completes, all pending microtasks drain, and the event loop gets to pick the next task. On a busy main thread with a long-running task, setTimeout(fn, 0) may not fire for hundreds of milliseconds despite a 0ms delay.
The Microtask Queue
After every task completes, V8 drains the entire Microtask Queue before doing anything else — before rendering, before picking the next task. Microtasks come from:
Promise.then()/Promise.catch()/Promise.finally()callbacksqueueMicrotask(fn)MutationObservercallbacksawaitresumptions (covered in Part 8)
"Drain entirely" means: if a microtask queues another microtask, that new microtask executes in the same drain pass. There is no escaping the microtask drain until the queue is empty. This has an important implication: a microtask that recursively queues more microtasks will starve the event loop indefinitely — the rendering step never executes, the next task never starts, the page appears frozen.
The Precise Ordering
console.log('1')
setTimeout(() => console.log('2'), 0) // Task Queue
Promise.resolve().then(() => console.log('3')) // Microtask Queue
queueMicrotask(() => console.log('4')) // Microtask Queue
console.log('5')
// Output: 1, 5, 3, 4, 2
Walk through this mechanically:
The initial script execution is one task. V8 executes it synchronously: logs 1, schedules a task for 2, queues a microtask for 3, queues a microtask for 4, logs 5. The current task (script execution) completes. Call stack is empty.
V8 drains the Microtask Queue: dequeues the 3 callback, executes it, logs 3. Dequeues the 4 callback, executes it, logs 4. Queue is empty.
V8 checks for rendering update. Not needed.
V8 dequeues the next task: the 2 callback. Executes it, logs 2.
Output: 1, 5, 3, 4, 2. Derivable, not memorisable.
requestAnimationFrame — Where It Fits
requestAnimationFrame(fn) schedules fn to run in the rendering step — after the current task and microtask drain, but before the next layout/paint. It is not a task, not a microtask. It is specifically tied to the browser's rendering pipeline.
setTimeout(() => console.log('timeout'), 0)
requestAnimationFrame(() => console.log('raf'))
Promise.resolve().then(() => console.log('promise'))
// Output (typically): promise, raf, timeout
// promise: microtask, drains before everything
// raf: rendering step, before next task
// timeout: next task
The "typically" caveat: requestAnimationFrame fires at the start of the next frame, which is tied to the display's refresh rate. If the browser decides no rendering update is needed, it may defer rAF. The ordering relative to tasks can vary. Do not depend on rAF for timing logic — use it only for animation frame synchronisation.
Part 8: Promises — What a Promise Object Physically Is
A Promise is not a language primitive. It is an ordinary JavaScript object with specific internal state and behaviour. Understanding its physical structure makes every Promise-related behaviour obvious.
A Promise object has three possible states:
pending: the operation has not completed yetfulfilled: the operation completed successfully, with a result valuerejected: the operation failed, with a reason
These are internal state fields on the object. The state transition is one-way: pending → fulfilled or pending → rejected. Never back to pending, never from fulfilled to rejected.
When you call .then(onFulfilled, onRejected) on a Promise, V8 does not call the callbacks immediately. It registers them as "reactions" on the Promise object. If the Promise is already settled (fulfilled or rejected), V8 immediately queues a microtask to invoke the appropriate callback. If the Promise is still pending, V8 stores the reaction and queues the microtask when the Promise eventually settles.
const p = new Promise((resolve, reject) => {
// This function (the executor) runs synchronously, right now
console.log('executor running')
resolve(42) // transitions p from pending to fulfilled, value = 42
})
p.then(value => {
// This callback does NOT run synchronously even though p is already fulfilled
// V8 queues it as a microtask
console.log('then:', value)
})
console.log('after then registration')
// Output:
// executor running
// after then registration
// then: 42
The executor runs synchronously — it is called immediately inside new Promise(...). The resolve(42) call transitions the Promise to fulfilled. But the .then() callback is always queued as a microtask, never called synchronously, even if the Promise is already settled. This ensures consistent asynchronous behaviour regardless of whether the underlying operation is synchronous or asynchronous.
Promise Chaining — The Physical Mechanism
.then() returns a new Promise. The value of that new Promise is determined by what the .then() callback returns. If the callback returns a plain value, the new Promise is fulfilled with that value. If it returns another Promise, the new Promise "follows" that Promise — it adopts the same state and value when the inner Promise settles.
Promise.resolve(1)
.then(v => v + 1) // returns 2 — new Promise fulfilled with 2
.then(v => v * 10) // returns 20 — new Promise fulfilled with 20
.then(v => console.log(v)) // 20
// Each .then() creates a new Promise
// Each new Promise is resolved by the previous callback's return value
// Each callback is scheduled as a microtask when the previous Promise fulfills
Promise.all, Promise.race, Promise.allSettled — Derived
Promise.all(promises) creates a new Promise that fulfills when all input Promises fulfill (with an array of values in the same order), and rejects immediately when any input Promise rejects.
The physical mechanism: V8 registers .then() reactions on each input Promise. A counter tracks how many have fulfilled. When the counter reaches the total, the output Promise is resolved. When any rejection reaction fires, the output Promise is immediately rejected. The order of fulfillment does not matter — the output array preserves input order using the index, not the fulfillment order.
Promise.race(promises) resolves or rejects with the first input Promise to settle. Physical mechanism: the first settlement triggers the output Promise; subsequent settlements are ignored (a settled Promise ignores further resolution attempts).
Promise.allSettled(promises) waits for all Promises to settle regardless of fulfillment or rejection, collects { status, value/reason } objects for each. Never rejects.
Part 9: async/await — What V8 Actually Does
async/await is syntactic sugar over Promises. Every async function returns a Promise. Every await expression is a Promise .then() in disguise. Once you see the desugaring, the behaviour becomes mechanical.
An async function, when called, immediately creates and returns a Promise. The function's body begins executing synchronously until the first await.
When V8 encounters await somePromise:
- It suspends the current function's execution (the execution context is preserved but removed from the call stack)
- It registers a microtask reaction on
somePromise— "when this settles, resume this function" - V8 returns control to the caller (or to the event loop if the call stack is now empty)
- When
somePromisesettles, V8 queues a microtask to resume the suspended function - The microtask executes: V8 restores the function's execution context, the
awaitexpression evaluates to the settled value, execution continues
This is precisely equivalent to .then(). The suspended function is the "rest of the function after await" — exactly what you would write in a .then() callback.
// These are identical in behaviour:
// async/await version:
async function getUser(id) {
const response = await fetch(`/users/${id}`)
const user = await response.json()
return user
}
// Promise version (what V8 approximately does internally):
function getUser(id) {
return fetch(`/users/${id}`)
.then(response => response.json())
.then(user => user)
}
The Execution Order of async Functions
This is the source of most async/await surprises in interviews. Walk through this precisely:
async function fetchData() {
console.log('A')
const result = await Promise.resolve('data')
console.log('B', result)
return result
}
console.log('1')
fetchData()
console.log('2')
// Output: 1, A, 2, B data
Mechanically: console.log('1') executes. fetchData() is called. Inside fetchData, console.log('A') executes synchronously. await Promise.resolve('data') is encountered. The function suspends: a microtask is queued to resume it. V8 returns to the caller. console.log('2') executes. The current task (script execution) completes. V8 drains the microtask queue: the suspension resume executes, console.log('B', result) executes, the function returns. Output: 1, A, 2, B data.
Everything before the first await is synchronous. Everything after is a microtask continuation. fetchData() returns a pending Promise immediately — not the result.
The await Ordering Rule
Every await costs at least one microtask tick. Two awaits cost two ticks. This matters when multiple async functions are running concurrently and you need to predict their interleaving.
async function first() {
console.log('first-1')
await null
console.log('first-2')
await null
console.log('first-3')
}
async function second() {
console.log('second-1')
await null
console.log('second-2')
}
first()
second()
// Output: first-1, second-1, first-2, second-2, first-3
Both functions run up to their first await, suspending there. Microtask queue has: resume-first, resume-second. Resume-first runs (first-2), hits second await, queues resume-first again. Resume-second runs (second-2), second completes. Resume-first runs (first-3). The functions interleave at each await point, in the order they were suspended.
Part 10: Generators — Functions That Pause
A generator is a function that can be paused mid-execution and resumed later. Not deferred like an async function — literally paused at a specific line, with its entire execution state (local variables, position in the code) preserved.
You write a generator function with a * after function. Inside, yield pauses execution and emits a value. Calling the generator function does not execute it — it returns an iterator, an object with a next() method.
function* counter() {
console.log('started')
yield 1
console.log('after first yield')
yield 2
console.log('after second yield')
yield 3
console.log('done')
}
const gen = counter() // creates the iterator — nothing executes yet
gen.next() // { value: 1, done: false }
// 'started' is logged, execution pauses at first yield
gen.next() // { value: 2, done: false }
// 'after first yield' is logged, execution pauses at second yield
gen.next() // { value: 3, done: false }
gen.next() // { value: undefined, done: true }
// 'after second yield', 'done' logged, function returns
Physically, what V8 does: a generator function, when called, creates a generator object — a special execution context that can be suspended. When next() is called, V8 resumes this context from wherever it was paused. The local variables, the position in the code, everything is preserved between next() calls. When yield is encountered, V8 freezes the context and returns control to the caller.
The Iterator Protocol
The object returned by a generator follows the iterator protocol: it has a next() method that returns { value, done }. done: false means more values are coming. done: true means the generator has returned.
Any object can implement this protocol. Generators are just the convenient way to create iterators. The for...of loop consumes any iterator:
for (const value of counter()) {
console.log(value) // 1, 2, 3
}
// for...of calls next() repeatedly until done: true
Arrays, Maps, Sets, Strings are all iterable — they have a [Symbol.iterator]() method that returns an iterator. This is why for...of works on all of them. The iterator protocol is the physical mechanism behind spread (...array), destructuring, and Array.from().
Generators for Async Control Flow
Generators are the conceptual precursor to async/await. Before async/await existed, generators were used with Promise-aware runners to achieve the same effect. Understanding this connection demystifies both.
// A generator that yields Promises:
function* fetchUserGen(id) {
const response = yield fetch(`/users/${id}`) // pause, emit the Promise
const user = yield response.json() // pause, emit the next Promise
return user
}
// A runner that drives the generator:
function run(generatorFn) {
const gen = generatorFn()
function step(value) {
const { value: yieldedValue, done } = gen.next(value)
if (done) return Promise.resolve(yieldedValue)
return yieldedValue.then(step) // when the yielded Promise resolves, resume
}
return step()
}
run(fetchUserGen) // works like an async function
async/await is essentially this pattern baked into the language. An async function is a generator. await is yield. The V8 runtime is the run() function. This is not a metaphor — it is approximately how V8 implements async/await internally.
Part 11: WeakMap and WeakRef — Language Semantics, Not Just Memory
You saw WeakMap and WeakRef in FE-07 from the memory management angle. You need them from the language semantics angle — why they exist as primitives, what problem they solve at the language level, and when to reach for them.
A regular Map holds strong references to its keys and values. As long as the Map exists, every key and value in it is reachable and cannot be garbage collected. This is correct when the Map is the owner of its data. It is wrong when the Map is an auxiliary structure — a cache, a metadata store — that should not prevent the primary data from being collected.
// Strong Map — prevents collection
const cache = new Map()
const element = document.createElement('div')
cache.set(element, { computedData: '...' })
element.remove() // removed from DOM
// But cache still holds a strong reference to element
// element cannot be collected — it is stuck in cache forever
// The associated computedData leaks with it
WeakMap solves this at the language semantics level. A WeakMap key is held weakly — the WeakMap does not count as a reference for garbage collection purposes. If the only path to an object is through a WeakMap key, the object is collected.
const cache = new WeakMap()
const element = document.createElement('div')
cache.set(element, { computedData: '...' })
element.remove()
// The element variable goes out of scope or is reassigned
// Now the only reference to the DOM element is the WeakMap key
// WeakMap keys are weak — the element is eligible for collection
// When it is collected, the WeakMap entry (including computedData) is also cleaned up
The WeakMap limitation — it cannot be iterated, has no size, no keys() — is not an oversight. It is required by the semantics. The presence of a key in a WeakMap depends on whether the key has been collected, which is non-deterministic (GC timing is not specified). Iteration would expose non-deterministic behaviour. The limitation enforces the correct usage pattern: you can only look up data for a specific object you already have a reference to.
The canonical use case: associating private data or computed metadata with objects you do not own — DOM elements, third-party objects, instances of classes whose source you cannot modify.
WeakRef is the single-object version: a reference that does not prevent collection. ref.deref() returns the object if still alive, or undefined if collected. Use for caches that should gracefully handle eviction under memory pressure. The FinalizationRegistry companion (covered in FE-07) lets you react to the collection event.
Vocabulary Map
| Term | What it physically is |
|---|---|
| Execution context | A data structure V8 creates for each function call — contains the variable environment (where variables live), the this binding, and the outer environment reference. Pushed onto the call stack when the function is called, popped when it returns |
| Call stack | A stack data structure in V8's memory holding the currently active execution contexts — last in, first out. The currently executing function is at the top |
| Variable environment | A record object in V8's heap holding all variables declared in a specific function or block. When you read or write a variable, V8 looks up or modifies an entry in this record |
| Outer environment reference | A pointer on every execution context to the variable environment of the lexically enclosing scope — the scope chain link. Determined at function definition time, not call time |
| Scope chain | The linked list of variable environments traversed when V8 looks up a variable — starts at the current context, follows outer references until the variable is found or null is reached |
| Lexical scoping | The rule that a function's outer environment reference is determined by where the function is written in the source code, not where or how it is called |
| Closure | A function object carrying a reference ([[Scope]]) to the variable environment in which it was defined — this reference keeps the environment alive in the heap even after the enclosing function has returned |
| Hoisting | V8's pre-execution pass that registers variable and function declarations in the variable environment before any code runs — var is initialised to undefined, function declarations are fully created, let/const are registered but not initialised (TDZ) |
| Temporal Dead Zone (TDZ) | The state of a let or const variable between when it is registered by hoisting and when its declaration line is executed — the variable exists in the scope but any access throws a ReferenceError |
| Prototype chain | The linked list of [[Prototype]] references traversed when V8 looks up a property — starts at the object, follows prototype references until the property is found or null is reached |
[[Prototype]] | An internal reference on every JavaScript object pointing to its prototype object — the link in the prototype chain. Accessible as Object.getPrototypeOf(obj) |
| Task Queue (Macrotask Queue) | A queue of work items (tasks) that the event loop processes one at a time — sources include setTimeout, setInterval, I/O callbacks, and user events. One task per event loop iteration |
| Microtask Queue | A queue of callbacks (microtasks) that V8 fully drains after every task, before rendering — sources include Promise callbacks and queueMicrotask. All microtasks run before the next task or rendering step |
| Event loop | V8's continuous process: execute one task → drain all microtasks → optionally render → repeat. The mechanism that makes JavaScript's single-threaded model feel concurrent |
| Promise | A JavaScript object representing an asynchronous operation with three possible states (pending, fulfilled, rejected) — .then() callbacks are registered as reactions and scheduled as microtasks when the Promise settles |
async function | A function that implicitly returns a Promise and whose execution is suspended at each await — the compiler transforms it into a state machine driven by Promise microtask callbacks |
await | A keyword that suspends the enclosing async function, registers a microtask continuation on the awaited Promise, and returns control to the caller — resumes execution when the Promise settles |
| Generator | A function (marked with *) whose execution can be paused at yield expressions and resumed by calling next() on the returned iterator — V8 preserves the full execution context between resumptions |
| Iterator protocol | The contract that an object with a next() method returning { value, done } is iterable by for...of, spread, and destructuring — generators implement this protocol automatically |
WeakMap | A Map variant where keys are held weakly — the WeakMap does not prevent garbage collection of its keys. When a key is collected, its entry is removed. Cannot be iterated because key presence is non-deterministic |
WeakRef | A reference to an object that does not prevent garbage collection — deref() returns the object if still alive or undefined if collected. Used for optional caches that gracefully handle eviction |
Practice Problems
Problem 1 — Failure Analysis
A React developer writes a custom hook for tracking mouse position:
function useMousePosition() {
const [position, setPosition] = useState({ x: 0, y: 0 })
useEffect(() => {
const handler = (e) => {
setPosition({ x: e.clientX, y: e.clientY })
}
window.addEventListener('mousemove', handler)
}, [])
return position
}
The hook works correctly but after a user navigates through 50 routes in the SPA, memory usage has grown by 200MB. Using what you know about closures, the scope chain, and what window.addEventListener retains, trace the exact retention graph that explains the memory growth. Then explain why the growth is linear with the number of navigations rather than constant.
Problem 2 — Design from Scratch
You need to implement a rate limiter function: rateLimit(fn, limit, interval) — returns a wrapped version of fn that can be called at most limit times per interval milliseconds. Calls that exceed the limit within the interval are dropped (not queued). Implement it using closures, without any external state. Your implementation must handle the case where calls arrive at irregular intervals correctly. Explain what variables you close over and why those specific variables must live in the outer scope rather than inside the wrapper function.
Problem 3 — Synthesis
Consider this code:
async function processItems(items) {
const results = []
for (const item of items) {
const result = await processOne(item)
results.push(result)
}
return results
}
async function processAll(items) {
return Promise.all(items.map(item => processOne(item)))
}
Both functions process the same items and return the same results array. Using your understanding of the event loop, how await works as a microtask suspension point, and how Promise.all physically works, explain the precise difference in execution between the two. If processOne makes a network request that takes 100ms, how long does each function take for 10 items? Why? Then explain a scenario where processItems (sequential) is the correct choice over processAll (parallel) — not from a business logic perspective but from a runtime behaviour perspective.
Problem 4 — Go Deeper
The event loop drains the entire Microtask Queue after every task before rendering. This means a microtask that queues another microtask, which queues another, can starve the render step indefinitely. Consider this pattern used by a developer to process a large array "asynchronously":
async function processLargeArray(items) {
for (const item of items) {
await process(item)
}
}
Where process(item) is synchronous but returns Promise.resolve(). The developer believes this is "yielding to the browser" on each iteration. It is not. Explain precisely why this pattern does NOT yield to the browser between iterations — trace through the microtask queue for the first three iterations. Then design the correct pattern that genuinely yields control to the browser between iterations (allowing rendering and user interaction), and explain why your pattern works where await Promise.resolve() does not.
Problem 5 — Previous Chapter Connection (FE-07)
In FE-07 you learned that closures are a primary source of memory leaks — a closure over a large object keeps that object alive as long as the closure itself is alive. You also learned that WeakRef can hold a reference without preventing collection. A developer is building a component framework where each component instance is associated with an event bus subscription. The subscription callback closes over the component instance (to call methods on it). When the component is "destroyed" (removed from the virtual tree), the subscription should clean up. The developer's first implementation uses a regular Map to track subscriptions. The second uses a WeakMap. The third uses a WeakRef inside the callback. For each of the three implementations, describe: (1) what the retention graph looks like after the component is destroyed, (2) whether the component can be garbage collected, and (3) the specific failure mode in production if the implementation is wrong. Which implementation is correct and why?
What's Coming Next
You now have the JavaScript runtime as a physical machine in your head. You know what V8 does at the moment of every function call, how variables are found, why closures retain memory, how this is resolved, how the event loop precisely orders execution, what a Promise physically is, how async/await suspends and resumes. With this foundation, FE-11 — Browser Security — becomes derivable rather than a list of rules to memorise. XSS is possible because the DOM executes injected scripts. CSRF is possible because browsers automatically send cookies with cross-origin requests. The Same-Origin Policy exists because JavaScript can read responses — without it, any page could impersonate any user on any other site. Content Security Policy is a header that restricts which scripts the browser will execute. Every browser security concept traces back to the JavaScript runtime model you now understand. The rules are not arbitrary — they are the minimum constraints necessary to make a world where untrusted code runs in the same process as your banking credentials even remotely safe.