Performance

Event Loop & Thread Management

How single-threaded runtimes handle concurrency and where blocking calls cause hidden damage.

Overview

The event loop is the core concurrency model in JavaScript (Node.js and browsers) and other single-threaded runtimes. It processes one task at a time from a task queue, runs microtasks (Promise callbacks, queueMicrotask) between tasks, and defers I/O callbacks, timers, and UI rendering to appropriate phases. Blocking the event loop with synchronous CPU work causes all I/O, timers, and UI updates to stall.

Origin

The event loop model was used in GUI systems (X Window, Amiga OS) before JavaScript. Netscape's JavaScript engine (Brendan Eich, 1995) used a single-threaded event loop for browser scripting. Node.js (2009) brought the model to server-side programming via libuv. The JavaScript specification (WHATWG HTML Living Standard) formally defines the event loop model including task queues and microtask checkpoints.

Examples

Understanding task vs microtask scheduling in TypeScript

// Execution order demonstration
console.log('1: synchronous');

setTimeout(() => console.log('2: setTimeout (macro-task)'), 0);

Promise.resolve().then(() => {
  console.log('3: Promise.then (microtask)');
  Promise.resolve().then(() => console.log('4: nested microtask'));
});

queueMicrotask(() => console.log('5: queueMicrotask'));

console.log('6: synchronous');

// Output order:
// 1: synchronous
// 6: synchronous
// 3: Promise.then (microtask)
// 4: nested microtask
// 5: queueMicrotask
// 2: setTimeout (macro-task)

// WHY: synchronous code runs first, then ALL microtasks drain before
// the event loop picks up the next macro-task (setTimeout callback)

Microtasks (Promise.then, queueMicrotask, MutationObserver) drain completely between macro-tasks. Infinitely resolving Promises will starve the event loop: the setTimeout callback never runs if microtasks keep queuing more microtasks.

Detecting and preventing event loop blocking in Node.js

import { performance } from 'perf_hooks';

// Monitor event loop lag: high lag means something is blocking
function monitorEventLoopLag(sampleIntervalMs = 100): NodeJS.Timeout {
  let lastCheck = performance.now();
  return setInterval(() => {
    const now = performance.now();
    const lag = now - lastCheck - sampleIntervalMs;
    if (lag > 50) {
      console.warn('Event loop lag detected: ' + lag.toFixed(0) + 'ms');
    }
    lastCheck = now;
  }, sampleIntervalMs);
}

// BAD: synchronous CPU work blocks event loop
function hashPasswordSynchronous(password: string): string {
  const bcrypt = require('bcryptjs');
  return bcrypt.hashSync(password, 12); // blocks for ~300ms
}

// GOOD: offload to worker thread
import { Worker, isMainThread, parentPort, workerData } from 'worker_threads';
function hashPasswordAsync(password: string): Promise<string> {
  return new Promise((resolve, reject) => {
    const worker = new Worker(__filename, { workerData: { password } });
    worker.on('message', resolve);
    worker.on('error', reject);
  });
}
if (!isMainThread) {
  const hash = require('bcryptjs').hashSync(workerData.password, 12);
  parentPort!.postMessage(hash);
}

Event loop lag is the primary health metric for Node.js services. clinic.js doctor measures it automatically. The --max-semi-space-size flag (V8 GC tuning) can reduce GC pauses that contribute to lag spikes.

Use Cases

  • 01Understanding why a Node.js server becomes unresponsive: a single CPU-bound operation blocks all concurrent requests
  • 02Diagnosing why Promises resolve in unexpected orders: microtask queue draining order explains many async bugs
  • 03Performance optimisation: identifying which operations should be offloaded to worker threads vs handled in-process async
  • 04Browser UI responsiveness: long-running JavaScript on the main thread blocks rendering and input handling (jank)

When Not to Use

  • //Do not use the event loop for CPU-bound tasks; move them to worker threads (Node.js) or Web Workers (browser)
  • //Do not add unnecessary setTimeout(0) calls expecting them to yield; they add 1-4ms minimum delay and only move work to the next macro-task, not the next render frame
  • //Do not over-use queueMicrotask for scheduling; deeply nested microtask chains can starve macro-tasks (I/O events, timers) by never yielding

Technical Notes

  • Node.js event loop phases (libuv): timers (setTimeout/setInterval) -> pending callbacks (deferred I/O errors) -> idle/prepare -> poll (blocking for I/O if nothing to do) -> check (setImmediate) -> close callbacks. process.nextTick runs between every phase
  • requestAnimationFrame in browsers is NOT microtask or macro-task; it is a rendering callback that runs between the "update the rendering" step of the event loop. It fires at 60fps (16.67ms) when the browser determines a new frame is needed
  • V8's GC runs incrementally and concurrently where possible, but major GCs (full mark-and-sweep) can still cause event loop pauses of 50-200ms on large heaps. Use --max-old-space-size carefully; larger heaps mean longer GC pauses
  • The WHATWG HTML spec defines "task source" priorities. Historically browsers ran tasks in FIFO order within a single queue; Chrome 87+ introduced separate task queues per task source with prioritisation (user input tasks have higher priority than network tasks)