2.0 Overview
JavaScript in the browser runs on a single thread. The engine consists of several
coordinating parts: a call stack that tracks active function frames,
a heap where objects are allocated, a task queue
that holds callbacks from I/O and timers, and a microtask queue
that drains between tasks. The event loop coordinates them all.
The diagram below shows how these parts relate. Click the image to enlarge it;
click the title bar to reduce it.
Fig 2.0 - JavaScript Execution Engine
2.1 Call Stack
When a function is called, the engine creates an execution context
and pushes it onto the call stack. The context holds the function's local variables,
its this binding, and a reference to the outer lexical environment (scope
chain). When the function returns the context is popped off.
function outer() {
console.log('outer start');
inner();
console.log('outer end');
}
function inner() {
console.trace(); // call stack: inner → outer → (anonymous)
}
outer();
// call stack at console.trace():
// inner ← top
// outer
// (anonymous) ← script root
Each frame on the stack is independent. Variables in one frame are not visible to
callers. Closures capture a reference to the outer lexical environment, not a copy
of its values, so they see mutations:
function makeCounter() {
let n = 0;
return () => ++n; // closure captures `n` by reference
}
const inc = makeCounter();
console.log(inc()); // 1
console.log(inc()); // 2
If the call stack grows too deep - typically from unbounded recursion - the engine
throws a RangeError: Maximum call stack size exceeded.
2.2 Heap
Objects, arrays, functions, and closures are allocated in an unstructured region of
memory called the heap. Stack variables hold references (pointers)
into the heap, not the values themselves.
const a = { x: 1 }; // object allocated on heap; `a` is a reference
const b = a; // b points to the same object
b.x = 99;
console.log(a.x); // 99 - same heap object
// primitives are values, not references
let p = 42;
let q = p;
q = 0;
console.log(p); // 42 - unaffected
The garbage collector reclaims heap memory when objects are no longer reachable from
any live root (call stack, global, or active closure). V8 uses a generational
collector: short-lived objects are collected frequently from "young space";
survivors are promoted to "old space" and swept less often. Most code never needs to
manage memory manually.
2.3 Task Queue
When an asynchronous operation completes - a timer fires, a fetch response arrives,
a user clicks a button - the browser places its callback on the task
queue (also called the event queue or message queue). The event loop picks
one task from the front of the queue only when the call stack is empty. Tasks run to
completion; no two tasks interleave.
console.log('start');
setTimeout(() => console.log('timer fires'), 0); // callback queued as a task
console.log('end');
// output: start end timer fires
// setTimeout callback runs only after the current synchronous script finishes
Sources of tasks: setTimeout, setInterval, I/O callbacks,
UI events (click, keydown, …), postMessage, and MessageChannel.
2.4 Microtask Queue
Promise callbacks (.then, .catch, .finally) and
queueMicrotask() callbacks go into the microtask queue,
which has higher priority than the task queue. After every task - including the
initial script evaluation - the engine drains the entire microtask queue
before picking the next task.
console.log('1 - sync');
Promise.resolve().then(() => console.log('3 - microtask'));
setTimeout(() => console.log('4 - task'), 0);
console.log('2 - sync');
// output: 1 - sync 2 - sync 3 - microtask 4 - task
Microtasks queued inside a microtask handler run in the same drain pass - before any
pending task. A loop that enqueues microtasks indefinitely will starve the task queue
and freeze the UI.
// execution order with nested microtasks
Promise.resolve()
.then(() => {
console.log('A');
return Promise.resolve('B'); // adds another microtask
})
.then(v => console.log(v)); // B runs before any task
setTimeout(() => console.log('C'), 0);
// output: A B C
2.5 The Event Loop
The event loop algorithm runs continuously:
- Run the current task to completion (call stack empties).
- Drain the microtask queue - run all microtasks, including any enqueued during this step.
- Render - the browser may update the display here (paint, layout).
- Pick the next task from the task queue and go to step 1.
// complete ordering example
console.log('1'); // sync - runs in current task
queueMicrotask(() => console.log('3')); // microtask queue
Promise.resolve()
.then(() => console.log('4')) // microtask queue
.then(() => console.log('5')); // queued during drain
setTimeout(() => console.log('6'), 0); // task queue
console.log('2'); // sync - still in current task
// output: 1 2 3 4 5 6
The practical implication: keep synchronous event handlers short. Any long-running
synchronous computation blocks steps 2-4 - no microtasks drain, no render occurs,
no other events fire - until it finishes.
2.6 Web Workers
A Web Worker runs JavaScript on a separate OS thread with its own
call stack, heap, task queue, and event loop. Workers cannot access the DOM or
window. Communication with the main thread uses postMessage,
which places a task on the receiver's queue.
// main.js
const worker = new Worker('worker.js');
worker.postMessage({ kind: 'sum', data: [1, 2, 3, 4, 5] });
worker.addEventListener('message', e => {
console.log('result:', e.data); // result: 15
});
// worker.js
self.addEventListener('message', e => {
if (e.data.kind === 'sum') {
const result = e.data.data.reduce((a, b) => a + b, 0);
self.postMessage(result);
}
});
Worker types:
-
Worker (dedicated) - one worker, one owner page.
-
SharedWorker - shared across tabs and pages from the same origin.
-
ServiceWorker - intercepts network requests, enables offline
caching and push notifications. Registered once, survives page reload.
// registering a service worker
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js')
.then(reg => console.log('SW registered:', reg.scope))
.catch(err => console.error('SW registration failed:', err));
}