StackPractices
intermediate By Mathias Paulenko

JavaScript Event Loop

Understand how the JavaScript event loop works internally and how to write non-blocking code.

Topics: frontend

Overview

Everything async in JavaScript — browsers and Node.js alike — runs through the event loop. It runs your code, picks up events, and works through queued tasks one by one. Once you get how the call stack, task queue, and microtask queue fit together, writing responsive, non-blocking code stops being guesswork.

When to Use

Use this resource when:

  • Debugging mysterious asynchronous bugs or race conditions — see race condition prevention for the patterns that cause them.
  • Optimizing UI responsiveness in frontend applications — see SPA code splitting for the bundle side of performance.
  • Picking between setTimeout, Promise, and queueMicrotask to schedule a job.
  • Understanding why code order doesn’t always match execution order.

When to Avoid

  • CPU-bound work: the event loop is single-threaded — offload to Web Workers instead.
  • Real-time audio/video processing: Web Audio API and WebRTC already run on dedicated threads — they never touch the main thread.
  • Server-side parallelism: worker threads or child processes beat the loop for CPU-bound server work.

Solution

Visualizing the Event Loop

console.log('1. Script start');

setTimeout(() => {
  console.log('2. setTimeout (macrotask)');
}, 0);

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

queueMicrotask(() => {
  console.log('4. queueMicrotask');
});

console.log('5. Script end');

// Output order:
// 1. Script start
// 5. Script end
// 3. Promise (microtask)
// 4. queueMicrotask
// 2. setTimeout (macrotask)

Handling Long-Running Tasks

function processLargeArray(arr, chunkSize = 1000) {
  let index = 0;

  function processChunk() {
    const chunk = arr.slice(index, index + chunkSize);
    chunk.forEach(item => heavyComputation(item));
    index += chunkSize;

    if (index < arr.length) {
      setTimeout(processChunk, 0); // Yield to event loop
    }
  }

  processChunk();
}

Async/Await and the Event Loop

async function fetchUserData(userId) {
  // Await yields control to the event loop
  const response = await fetch(`/api/users/${userId}`);
  const data = await response.json();
  return data;
}

// Multiple awaits run sequentially
async function fetchSequential() {
  const a = await fetch('/api/a'); // waits for a
  const b = await fetch('/api/b'); // waits for b
  // Total time: time(a) + time(b)
}

// Promise.all runs concurrently
async function fetchParallel() {
  const [a, b] = await Promise.all([
    fetch('/api/a'),
    fetch('/api/b'),
  ]);
  // Total time: max(time(a), time(b))
}

Web Workers for CPU-Intensive Tasks

// main.js
const worker = new Worker('compute.js');

worker.postMessage({ data: largeArray });
worker.onmessage = (e) => {
  console.log('Result:', e.data);
};

// compute.js
self.onmessage = (e) => {
  const result = heavyComputation(e.data);
  self.postMessage(result);
};

Workers live on their own thread, so they never block the loop or the UI.

Scheduler API (Modern Browsers)

// Yield to the main thread with scheduler.yield()
async function processChunks(items) {
  for (const item of items) {
    process(item);
    await scheduler.yield(); // Let the browser paint
  }
}

// Schedule tasks with priorities
scheduler.postTask(() => {
  // user-visible task
}, { priority: 'user-visible' });

scheduler.postTask(() => {
  // background task
}, { priority: 'background' });

Node.js: setImmediate vs process.nextTick

// process.nextTick runs before any I/O or timers
process.nextTick(() => console.log('1. nextTick'));

// setImmediate runs after I/O events
setImmediate(() => console.log('3. setImmediate'));

// setTimeout runs after a minimum delay
setTimeout(() => console.log('2. setTimeout'), 0);

// Order: 1. nextTick, 2. setTimeout, 3. setImmediate
// (setTimeout vs setImmediate order is not guaranteed in practice)

Explanation

The loop cycles through a few stages:

  1. Call Stack: runs synchronous code. Once it empties, the loop looks at the queues.
  2. Microtask Queue: Promise callbacks, queueMicrotask, and MutationObserver callbacks — all drained before the next macrotask.
  3. Macrotask Queue: setTimeout, setInterval, setImmediate (Node.js), and I/O events — one task per loop turn.
  4. Render Phase: the browser repaints whenever it gets a window.

Critical rule: all microtasks execute before the next macrotask. This can starve the macrotask queue if microtasks recursively enqueue more microtasks.

flowchart diagram: Call Stack<br/>sync code

Variants

RuntimeMacrotask APIMicrotask APINotes
BrowsersetTimeout, requestAnimationFramePromise, queueMicrotaskrAF runs before paint
Node.jssetTimeout, setImmediatePromise, process.nextTicknextTick runs before Promises
DenosetTimeoutPromise, queueMicrotaskAligns with browser behavior
BunsetTimeout, setImmediatePromise, queueMicrotasknextTick not supported

Advanced: Measuring Event Loop Lag

// Node.js: measure event loop delay
let lastCheck = performance.now();

setInterval(() => {
  const now = performance.now();
  const lag = now - lastCheck - 1000; // expected 1000ms interval
  if (lag > 10) {
    console.warn(`Event loop lag: ${lag.toFixed(1)}ms`);
  }
  lastCheck = now;
}, 1000);
// Browser: measure long tasks with PerformanceObserver
const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    console.warn(`Long task: ${entry.duration.toFixed(0)}ms`);
  }
});
observer.observe({ entryTypes: ['longtask'] });

Anything past 50ms gets flagged as a long task — those are the ones users feel as jank.

Advanced: AbortController for Cancellation

const controller = new AbortController();

async function fetchWithTimeout(url, ms) {
  const timer = setTimeout(() => controller.abort(), ms);
  try {
    const res = await fetch(url, { signal: controller.signal });
    return await res.json();
  } finally {
    clearTimeout(timer);
  }
}

When aborted, fetch rejects with an AbortError — that’s the standard cancellation mechanism for async work.

Companion code: the javascript-event-loop companion repo has the ordering demo, chunked processing, and lag monitor as scripts you can run with plain node.

What Works

  • Break heavy work into chunks: use setTimeout or requestIdleCallback to yield control
  • Prefer microtasks for DOM updates: queueMicrotask batches your DOM reads
  • Avoid recursive microtask enqueuing: can freeze the event loop indefinitely
  • Use requestAnimationFrame for visual updates: synchronizes with the browser’s render cycle
  • Profile with the Performance tab: Chrome DevTools visualizes microtask and macrotask timing

Common Mistakes

  1. Assuming setTimeout(0) is immediate: it’s always slower than microtasks
  2. Blocking the main thread: synchronous loops >50ms cause jank and dropped frames
  3. Forgetting nextTick in Node.js: process.nextTick runs before Promises, not after
  4. Mixing microtask recursion: Promise.resolve().then(() => Promise.resolve().then(…)) can deadlock
  5. Ignoring the render phase: heavy microtask queues prevent browser painting

Troubleshooting the Event Loop

  • UI freezes on large data: a synchronous loop is hogging the stack. Break it into setTimeout chunks or scheduler.yield(), and watch for long tasks >50ms in the Performance tab.
  • Timer never fires as expected: a recursive microtask chain is starving the macrotask queue. Trace what enqueues microtasks — a .then() that resolves another resolved Promise loops forever.
  • setInterval callbacks stack up: the callback takes longer than the interval, so executions queue back-to-back. Switch to recursive setTimeout so the next run waits for the previous one.
  • Unhandled rejection storms: a Promise failing without .catch() logs per event-loop turn and can flood monitoring. Catch early, or funnel everything through one unhandledrejection handler.
  • Animation stutters despite “async” code: microtasks still run before paint — a heavy await chain delays rendering. Move visual updates into requestAnimationFrame and heavy work to a Worker.

Production Notes

  • Track event loop lag as a metric — a rising lag curve under load means the service can’t process I/O, not that it’s “slow”. Alert on p99 lag in Node.js services.
  • Budget long tasks in the browser: keep tasks under 50ms so the render phase gets its turn; measure with longtask entries in RUM.
  • Kill-switch CPU-heavy endpoints behind timeouts or worker offloading — one synchronous parse loop can starve every other request on the process.
  • Log microtask-heavy hot paths: recursive Promise chains that starve macrotasks show up as timer drift before they show up as outages.

Key Takeaways

  • The order is: sync code → all microtasks → one macrotask → render. Everything else follows from that.
  • await doesn’t block — it suspends and re-queues the continuation as a microtask.
  • If the UI janks, a task is too long; if timers starve, the microtask queue is too busy.
  • Web Workers are the answer for CPU-bound work; the loop itself never parallelizes.

Common Production Pitfalls

  • Assuming “async code” means “non-blocking” — microtasks also run before paint.
  • Measuring event loop lag only on localhost; timer drift in production tells a different story.
  • Using setInterval for high-frequency polling and letting callbacks stack up under load.
  • Parsing heavy JSON on the main thread; synchronous JSON.parse blocks too.
  • Creating a Worker but keeping the heavy work on the main thread “for now”.
  • Never testing the recursive microtask queue — macrotask starvation doesn’t show up until production.

See Also

Frequently Asked Questions

Why does Promise.then() run before setTimeout(0)?

Promise callbacks land in the microtask queue, and the loop always empties that queue before touching the next macrotask — setTimeout callbacks included.

What is the difference between queueMicrotask and Promise.resolve().then()?

They behave the same in practice; queueMicrotask just says what it does and skips allocating a Promise object you'd never use. Reach for it when there's no Promise chain to continue.

How do I prevent the event loop from freezing?

Break work into small chunks using setTimeout, requestIdleCallback, or Web Workers for CPU-intensive tasks. Use scheduler.yield() in modern browsers. Monitor with PerformanceObserver for long tasks.

What is the difference between microtask and macrotask?

Microtasks — Promise callbacks, queueMicrotask, MutationObserver — fire right after the current task, ahead of any macrotask. Macrotasks (setTimeout, setInterval, I/O) come from the loop's own scheduler, and the whole microtask queue has to empty before the next one runs.

How does async/await relate to the event loop?

await suspends the async function and yields control to the event loop. When the awaited Promise resolves, the continuation is enqueued as a microtask. This means async functions don't block the event loop — they cooperatively yield.

What is requestAnimationFrame and when should I use it?

requestAnimationFrame schedules a callback before the browser's next paint. It runs in the render phase, after microtasks but before the actual paint. Use it for visual updates: DOM mutations, canvas drawing, CSS transitions. It syncs with the display refresh rate (usually 60Hz).

What is the Node.js event loop model?

Node.js uses libuv, which has phases: timers, pending callbacks, idle/prepare, poll, check (setImmediate), and close callbacks. Between each phase, microtasks (process.nextTick and Promises) are drained. This is more structured than the browser model.

How do I measure event loop performance?

In Node.js, use perf_hooks to measure event loop lag. In browsers, use PerformanceObserver with the longtask entry type. Chrome DevTools Performance tab visualizes task timing. Look for tasks longer than 50ms.

Can the event loop run in parallel?

No — the loop is single-threaded by design. Web Workers and Node.js worker threads each get their own loop on their own thread, and SharedArrayBuffer plus Atomics give you a narrow channel for shared memory between them.

What happens when the event loop is blocked?

The UI freezes: no animations, no clicks, no scroll. On a Node.js server, I/O stops and timers go silent — the process looks dead. Blocking the main thread with synchronous work is the single most common performance problem in JavaScript, and it's exactly why this topic matters.