JS Story

JS Story: Execution Engine

call stack, heap, task queue, microtasks, event loop, workers

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:
  1. Run the current task to completion (call stack empties).
  2. Drain the microtask queue - run all microtasks, including any enqueued during this step.
  3. Render - the browser may update the display here (paint, layout).
  4. 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));
}