4 min read

Demystifying the JavaScript Event Loop: A Practical Guide to Concurrency in Modern Apps

Learn how the event loop, task queues, and microtasks shape JavaScript’s concurrency model with real‑world code examples.
Demystifying the JavaScript Event Loop: A Practical Guide to Concurrency in Modern Apps

Introduction

JavaScript is famously single‑threaded, yet we write highly responsive web apps, server‑side services, and even desktop tools that appear to run many things at once. The secret sauce is the event loop—a lightweight scheduler that coordinates macrotasks, microtasks, and I/O callbacks. Understanding how this machinery works is essential for writing performant code, avoiding subtle bugs, and making the most of modern APIs like Promise, async/await, and Web Workers.

This article walks through the event loop step‑by‑step, explains the concurrency model, and shows practical patterns you can adopt today.


1. The Core Concepts

Concept What it is Where it lives
Call Stack The execution context stack where synchronous code runs. Main JavaScript thread
Macrotask Queue (or Task Queue) Holds callbacks from setTimeout, setInterval, I/O, UI events, etc. Managed by the host (browser/Node)
Microtask Queue Holds Promise reaction jobs and queueMicrotask callbacks. Processed after each macrotask, before rendering
Event Loop Repeatedly picks the next macrotask, runs it, then drains the microtask queue. Host‑provided scheduler

The event loop never runs two pieces of JavaScript simultaneously. Instead, it interleaves work: a macrotask runs to completion, then all pending microtasks run, then the browser may repaint, and the cycle repeats.


2. A Minimal Visualisation

┌─────────────────────┐
│   Call Stack (JS)   │
└─────────▲───────────┘
          │
   ┌──────┴───────┐
   │ Event Loop   │
   └──────▲───────┘
          │
   ┌──────┴───────┐
   │ Macrotask Q │   ← setTimeout, I/O, UI events
   └──────▲───────┘
          │
   ┌──────┴───────┐
   │ Microtask Q │   ← Promise jobs, queueMicrotask
   └──────────────┘

After a macrotask finishes, the loop empties the microtask queue before moving to the next macrotask. This ordering explains many “surprising” behaviours in async code.


3. Real‑World Example: Ordering with setTimeout and Promises

console.log('A');

setTimeout(() => console.log('B'), 0);

Promise.resolve()
  .then(() => console.log('C'))
  .then(() => console.log('D'));

console.log('E');

Output

A
E
C
D
B

Why?

  1. Synchronous logs (A, E) run on the call stack.
  2. setTimeout schedules a macrotask (B).
  3. Promise.resolve().then creates two microtasks (C, D). They run immediately after the current macrotask (the script) finishes.
  4. Only after the microtask queue is empty does the event loop pick the next macrotask (B).

4. Macrotasks vs. Microtasks – When to Use Which

Situation Prefer Macrotask Prefer Microtask
UI repaint after state change requestAnimationFrame (macrotask)
Deferring heavy computation without blocking UI setTimeout(..., 0)
Chaining async work that must run before the next render Promise.then / queueMicrotask
Scheduling work that should not starve the UI setTimeout with a small delay ❌ (microtasks run before paint)

Rule of thumb: Use microtasks for logical continuation of the current task (e.g., resolve a promise). Use macrotasks when you need to yield back to the host, giving it a chance to handle I/O, repaint, or user input.


5. The Event Loop in Node.js

Node adds a few extra queues:

Queue Typical Sources
Timers setTimeout, setInterval
I/O Callbacks File system, network sockets
Idle, Prepare Internal bookkeeping
Poll Retrieves new I/O events
Check setImmediate callbacks
Close Callbacks socket.on('close')

Node’s setImmediate is a macrotask that runs after the poll phase, making it useful for “run after I/O but before timers”. In browsers, the closest analogue is requestAnimationFrame for UI‑aligned work.

Node example

const fs = require('fs');

fs.readFile('data.txt', (err, data) => {
  console.log('file read'); // I/O callback (macrotask)
});

setImmediate(() => console.log('immediate')); // runs after I/O callbacks
setTimeout(() => console.log('timeout'), 0);   // runs after immediate

Typical output:

file read
immediate
timeout

6. Async/Await – Syntactic Sugar Over Promises

async functions pause execution at each await. Under the hood, the pause is implemented by returning a promise and queuing the continuation as a microtask.

async function fetchAndLog(url) {
  console.log('start');
  const resp = await fetch(url); // pause, schedule continuation as microtask
  console.log('status', resp.status);
}
console.log('before');
fetchAndLog('https://api.example.com');
console.log('after');

Possible output

before
start
after
status 200

The await does not block the thread; it merely yields control back to the event loop, allowing other macrotasks (e.g., UI events) to run while the network request resolves.


7. Avoiding Common Pitfalls

7.1. Unbounded Microtask Queues

If a microtask enqueues another microtask, the loop can starve the macrotask queue, causing UI freezes.

function flood() {
  Promise.resolve().then(flood);
}
flood(); // ❌ infinite microtask recursion

Fix: Use setTimeout or queueMicrotask with a guard, or restructure logic to avoid recursive microtasks.

7.2. Mixing setTimeout(..., 0) and Promise.then

Developers sometimes think setTimeout(..., 0) is “next tick”. It’s actually a macrotask, so any then callbacks will run before it, which can be surprising.

7.3. Blocking the Call Stack

Heavy synchronous loops (while(true){}) block the event loop entirely, preventing any task from executing. Offload CPU‑intensive work to Web Workers (browser) or worker threads (Node) to keep the main thread responsive.


8. Practical Patterns

8.1. Debouncing with Microtasks

When you need to batch rapid UI updates (e.g., on input events), a microtask‑based debounce is ultra‑lightweight:

let pending = false;
function microDebounce(fn) {
  if (!pending) {
    pending = true;
    queueMicrotask(() => {
      pending = false;
      fn();
    });
  }
}
input.addEventListener('input', e => microDebounce(() => render(e.target.value)));

Because microtasks run before the next paint, the UI updates only once per animation frame.

8.2. Throttling with requestAnimationFrame

For animation‑related work, combine the event loop with the browser’s paint cycle:

let ticking = false;
function rafThrottle(callback) {
  return function (...args) {
    if (!ticking) {
      ticking = true;
      requestAnimationFrame(() => {
        ticking = false;
        callback.apply(this, args);
      });
    }
  };
}
window.addEventListener('scroll', rafThrottle(() => {
  // expensive DOM calculations
}));

8.3. Graceful Shutdown in Node

When a server receives SIGTERM, you often want to finish pending requests before exiting. Use the microtask queue to wait for all in‑flight promises:

process.on('SIGTERM', async () => {
  console.log('Shutting down...');
  await Promise.all(activeRequests); // microtasks
  server.close(() => process.exit(0));
});

The await ensures the event loop processes any remaining microtasks (e.g., DB writes) before the process exits.


9. Visual Debugging Tools

Tool What it Shows
Chrome DevTools → Performance Timeline of macrotasks, microtasks, and paint events
Node --trace-event Low‑level trace of libuv phases and task queues
async_hooks (Node) Hook into promise lifecycle to see when microtasks are created/completed

Using these tools, you can spot unexpected microtask bursts or long‑running macrotasks that degrade responsiveness.


10. Summary

  • The JavaScript runtime is single‑threaded, but the event loop orchestrates asynchronous work via macrotasks and microtasks.
  • Microtasks (Promise.then, queueMicrotask) run immediately after the current macrotask, before any rendering.
  • Macrotasks (setTimeout, I/O callbacks, setImmediate) give the host a chance to handle I/O, repaint, and user input.
  • Understanding the ordering lets you write deterministic async code, avoid starvation, and choose the right primitive for each scenario.
  • Real‑world patterns—micro‑debounce, RAF throttling, graceful shutdown—leverage the event loop to keep apps responsive and robust.

Mastering the event loop isn’t just academic; it’s the foundation for writing fast, reliable JavaScript—whether you’re building a single‑page UI, a high‑throughput API, or a desktop Electron app.