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

The JavaScript event loop is the heart of asynchronous programming in browsers and Node.js. It orchestrates the execution of code, collects and processes events, and executes queued sub-tasks. Understanding how the call stack, task queue, and microtask queue interact is essential for writing performant, non-blocking applications.

When to Use

Use this resource when:

  • Debugging mysterious asynchronous bugs or race conditions. See Unit Testing for testing async code.
  • Optimizing UI responsiveness in frontend applications. See SPA Code Splitting for frontend performance.
  • Choosing between setTimeout, Promise, and queueMicrotask. See WebSockets Real-Time for event-driven frontend patterns.
  • Understanding why code order does not always match execution order. See Parse JSON for handling async data parsing.

When to Avoid

  • CPU-bound work: The event loop is single-threaded.
  • Real-time audio/video processing: Use Web Audio API or WebRTC, which run on separate threads.
  • Server-side parallelism: Node. js worker threads or child processes are better for CPU-bound server tasks.

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);
};

Web Workers run on a separate thread. They do not block the event 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 event loop operates in phases:

  1. Call Stack: Executes synchronous code. When empty, the event loop checks queues.
  2. Microtask Queue: Processes Promise callbacks, queueMicrotask, and MutationObserver callbacks. Cleared entirely before next macrotask.
  3. Macrotask Queue: Processes setTimeout, setInterval, setImmediate (Node.js), and I/O events.
  4. Render Phase: Browsers may update the DOM and repaint if time allows.

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

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'] });

Tasks longer than 50ms are reported as long tasks. These cause visible 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. This is the standard way to cancel async operations.

What Works

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

Common Mistakes

  1. Assuming setTimeout(0) is immediate: It is 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

  • Component does not re-render: verify state reference, props, and memoization. A mutated object can bypass change detection.
  • Style does not apply in production: check that CSS is loaded, class names are not mangled, and specificity wins. Purge unused styles carefully.
  • Build fails after dependency update: read the changelog, pin versions, and clean the lock file.
  • Accessibility audit fails: add labels, landmarks, focus management, and color contrast.
  • Hydration mismatch: ensure server and client render the same initial HTML. random, or window during SSR.

Production Notes

  • Deploy gradually using canary or blue-green to catch regressions early.
  • Configure alerts for error rate, p99 latency, and failure rate before enabling in production.
  • Document the rollback in the runbook; test the procedure in staging at least once per quarter.
  • Review structured logs with correlation IDs to trace requests end-to-end during incidents.

Key Takeaways

  • Apply javascript event loop when you need a practical solution for your use case.
  • Monitor performance after implementation; measure latency, errors, and resource usage before and after.
  • Check the Troubleshooting section for common failures; most have documented root causes with fixes.
  • Keep dependencies updated and run tests in CI to prevent production regressions.

Common Production Pitfalls

  • Copying the example without adapting it to real data volumes and failure modes.
  • Skipping load and error-injection tests before the first production deployment.
  • Hard-coding values that should be configurable per environment.
  • Forgetting to add logging and monitoring at each step.
  • Deploying without a rollback plan or a tested backup strategy.
  • Assuming the minimal example will scale without adding caching or batching.
  • Not documenting the version and configuration used in production.
  • Letting the recipe sit unchanged when dependencies or scale evolve.

Frequently Asked Questions

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

Promise callbacks enter the microtask queue, which has higher priority than the macrotask queue where setTimeout callbacks live. The event loop drains all microtasks before processing the next macrotask.

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

Functionally identical in most cases, but queueMicrotask is more explicit and slightly more efficient. It avoids creating a Promise object. Use queueMicrotask when you do not need the Promise chain.

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) run after the current task completes and before the next macrotask. Macrotasks (setTimeout, setInterval, I/O events) are scheduled by the event loop. All microtasks drain before the next macrotask 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 do not 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 longtask entry type. Chrome DevTools Performance tab visualizes task timing. Look for tasks longer than 50ms.

Can the event loop run in parallel?

No. JavaScript is single-threaded in the event loop. Web Workers and Node.js worker threads run separate event loops on separate threads. SharedArrayBuffer and Atomics allow limited shared memory communication.

What happens when the event loop is blocked?

The UI freezes. No animations, no clicks, no scroll. In Node.js, no I/O is processed, no timers fire. The server becomes unresponsive. This is why blocking the main thread with synchronous work is the most common performance issue in JavaScript.