Skip to content

Deep dive into the event loop

Javascript is single-threaded meaning it executes a single task at a time. It has one call stack which means that when a function is called it is placed at the top of the stack, the Javascript engine works its way down executing one function at a time, removing it from the stack when its done.

Because of its single-threaded nature, long-running tasks block the main thread, making the browser unresponsive. The event loop schedules non-blocking operations to keep JavaScript responsive. Its basic cycle is: call stack → task queue → microtasks → macrotasks.

What is the event loop?

Imagine a busy restaurant kitchen with a single chef who represents the JavaScript main thread. The chef works on a cutting board, which is the call stack, handling one dish at a time in the exact order it’s received. Simple orders, like a sandwich, are prepared directly on the cutting board (synchronous functions). But if a customer orders something complex like a roast, the chef preps it quickly and hands it off to the oven, which represents the Web APIs, without standing idle to wait. While the roast cooks in the oven, the chef keeps handling other dishes. When the roast is done, the oven dings and it’s placed on the pickup counter, which is the callback queue. The chef, guided by the event loop, constantly checks whether the cutting board is clear; as soon as it is, they grab the roast from the counter and serve it, completing the original task. Microtasks like Promises and queueMicrotask are small prep notes the chef must always clear before touching anything from the pickup counter, while macrotasks like timers or I/O are the roasts waiting there. If the chef gets buried in endless prep notes, they never make it back to the counter (microtask starvation), while too many giant roasts delay quick sandwich orders (delayed input). In this way, the analogy makes it clear: the chef never multitasks, the oven works in parallel, and the callback is not a “final touch” but part of finishing the original order — keeping the kitchen (and JavaScript’s event loop) running smoothly.

Can the event loop can impact our apps?

Common questions include:

  • Why does a search box freeze when new data streams in?
  • Why do animations stutter even when CPU usage isn’t maxed out?
  • Why does a modal show “too soon” and block rendering? These issues often arise from inefficient use of the event loop. We’ll explore three scenarios and their fixes

But before then lets expound more on the event loop

  • JavaScript is single-threaded, meaning it has one main thread and can only execute one piece of code at a time. However, it creates the illusion of concurrency—multiple things like fetching data, responding to clicks, or running animations appear to happen simultaneously. In reality, JavaScript is just scheduling tasks cleverly: the browser or Node.js handles long-running operations (such as network requests or timers) in the background, and when they’re complete, they schedule callbacks for JavaScript to process later through the event loop.

  • Microtasks (such as those created with Promises or queueMicrotask) are tiny “next steps” that JavaScript schedules to run immediately after the current function finishes. They always run right after the call stack is cleared but before any macrotasks like timers, events, or I/O callbacks. Common examples include .then() handlers on Promises and calls to queueMicrotask().

  • Macrotasks (such as setTimeout, setInterval, I/O events, or MessageChannel) are larger scheduled jobs that run after the current call stack finishes and all microtasks have been drained. Once microtasks are cleared, the event loop picks the next macrotask from the queue and executes it. This ordering explains why Promise.resolve().then(...) (a microtask) always runs before setTimeout(fn, 0) (a macrotask).

  • In Node.js, there’s an important nuance: process.nextTick(Node.js-specific microtask with higher priority) schedules a callback to run immediately after the current function finishes, but before any other microtasks like Promises. This allows it to effectively “jump the line,” giving it higher priority in the event loop. While powerful for quick, critical tasks, it can be dangerous if overused—too many process.nextTick calls can starve the event loop in the same way as excessive microtasks, preventing timers, I/O, and other scheduled work from running.

JavaScript executes one function at a time on the call stack; once the stack is empty, it drains all microtasks (Promises, queueMicrotask) before processing the next macrotask (setTimeout, I/O), while in Node.js, process.nextTick runs even before microtasks

Let’s explore common pitfalls developers encounter when the event loop is misused, starting with microtask starvation

Scenario 1 -> Microtasks starving the UI

Imagine you’re building a real-time dashboard where a search box handles input events (setInterval here will simulates typing) then a data-processing loop is chained through Promises.

js
// Simulate typing every 100ms
setInterval(() => {
  console.log(" User typed (macrotask)");
}, 100);

// BAD: microtask storm
function startStorm() {
  Promise.resolve().then(function again() {
    console.log(" Microtask running...");
    Promise.resolve().then(again); // schedules another microtask immediately
  });
}

console.log(" App started");
// startStorm(); // uncomment to freeze input

In the above code if startStorm is commented keystrokes log normally if its uncommented we get endless “ Microtask running…” with no keystrokes show up. The user interface feels frozen, but the CPU isn’t maxed. This happens because microtasks always drain before macrotasks and by chaining Promises forever, the queue never clears thus input events (macrotasks) wait forever making the user thinks “the app is broken.” This is a problem that can be found in streaming APIs (chat, trading dashboards, collaborative editors) where each chunk schedules a .then() or where retry loops are accidentally written as recursive Promises instead of timers.

  • Fix 1 Yield occasionally with macrotasks

Here we break infinite chains by yielding back to the event loop. We introduce a yield: every 100 iterations (if (++counter % 100 === 0)), it uses setTimeout(boundedStorm, 0) to schedule the next call as a macrotask instead. This pause lets the event loop process other pending work (like keystrokes or rendering) before continuing, ensuring responsiveness while still performing many small asynchronous steps.

js
let counter = 0;
function boundedStorm() {
  Promise.resolve().then(() => {
    console.log(" Microtask iteration", counter);
    if (++counter % 100 === 0) {
      setTimeout(boundedStorm, 0); // yield - let keystrokes through
    } else {
      boundedStorm();
    }
  });
}
boundedStorm();
  • Fix 2 Batch updates: Instead of scheduling 1,000 microtasks for 1,000 updates → collect them, then apply once:
js
let pendingUpdates = [];
function scheduleUpdate(update) {
  pendingUpdates.push(update);
  queueMicrotask(() => {
    console.log("Apply batched updates:", pendingUpdates);
    pendingUpdates = [];
  });
}
  • Fix 3 Offload to Web Workers for real parallelism.

Tip

Avoid chaining infinite microtasks. Use macrotasks to yield control and keep the UI responsive.

Scenario 2: Heavy Work Makes Animations Janky

In the code below we simulate heavy synchronous work. First, two timers are set up: setTimeout(() => console.log("Render chart"), 0) and setTimeout(() => console.log(" Render news"), 0). These tell JavaScript, “Once you’re free, please run these tasks,” but they won’t execute immediately; they sit in the task queue until the main thread is clear. Next, a message is logged and heavy work begins with console.log("Doing heavy work...") followed by a call to heavyComputation(), which is a loop that runs for about 25 milliseconds doing nothing useful (while (performance.now() - start < 25) {}). The issue is that this loop blocks the main thread, so nothing else — including the previously scheduled timers — can run while it executes. After the heavy work, another timer is scheduled with setTimeout(() => console.log("Show modal"), 0), which also waits in the task queue, ready to execute only when the main thread is free.

js
// Simulate heavy sync work (25ms)
function heavyComputation() {
  const start = performance.now();
  while (performance.now() - start < 25) {} // blocks thread
}

setTimeout(() => console.log("Render chart"), 0);
setTimeout(() => console.log("Render news"), 0);

console.log("Doing heavy work...");
heavyComputation();

setTimeout(() => console.log("Show modal"), 0);
  • Everything runs late. In a real browser: animations stutter, input feels laggy as it renders Doing heavy work... Render chart Render news Show modal

  • JavaScript being single-threaded, a long synchronous task (25ms+) hogs the main thread. thus frames (budget = ~16ms for 60fps) are missed causing jank.

  • This is commonly experienced when parsing large CSVs or JSON blobs, in rendering giant charts or tables and in image manipulation, or PDF generation.

  • Fix Chunk with microtasks

js
function processInChunks(items) {
  if (items.length === 0) return;
  const chunk = items.splice(0, 100); // process 100 at a time
  console.log("Processed", chunk.length);

  setTimeout(() => processInChunks(items), 0); // yield to event loop
}

In the code above processInChunks function takes an array of items and processes them in smaller batches of 100 at a time to avoid blocking the main thread. If the array is empty, it simply returns. Otherwise, it removes the first 100 items using splice and logs how many items were processed. Instead of processing the next batch immediately, it schedules the next call with setTimeout(..., 0), effectively yielding control back to the event loop so that other tasks — such as user input or rendering — can run, ensuring the application remains responsive even when handling large datasets.

Scenario 3: Subtle Ordering Bugs Between Microtasks & Macrotasks

In the code below we simulate a checkout process where fakePayment returns a Promise that resolves after 100 milliseconds to mimic a payment confirmation. When checkout() is called, it first logs " User clicks checkout". Once the payment Promise resolves, it logs the confirmation message "Payment confirmed". Immediately after, a microtask is queued using queueMicrotask to update the order status (" Update order status"), ensuring this important state change happens before any macrotasks. Finally, a macrotask is scheduled with setTimeout(..., 0) to show the success modal (" Show success modal"), deferring this user-facing action until the event loop has processed all current microtasks. This pattern demonstrates the senior-level principle of using microtasks for correctness and macrotasks for UI/UX deferral.

js
function fakePayment() {
  return new Promise(res => setTimeout(() => res("Payment confirmed"), 100));
}

async function checkout() {
  console.log(" User clicks checkout");

  fakePayment().then(msg => {
    console.log(msg);

    // Correctness: microtask
    queueMicrotask(() => {
      console.log("Update order status");
    });

    // UX deferral: macrotask
    setTimeout(() => {
      console.log(" Show success modal");
    }, 0);
  });
}

checkout();

Note

If fakePayment rejects, none of the microtask/macrotask code runs

  • Fix Add error handling
js

fakePayment()
  .then(msg => { /* success */ })
  .catch(err => console.error("Payment failed:", err));
  • Better solution using async/await for clarity while preserving order
js
async function checkout() {
  console.log(" User clicks checkout");
  try {
    const msg = await fakePayment();
    console.log(msg);
    console.log("Update order status"); // microtask-like ordering naturally
    setTimeout(() => console.log(" Show success modal"), 0); // macrotask for UX
  } catch (err) {
    console.error("Payment failed:", err);
  }
}

javascript book

If this interested you, check out my Javascript Book

Enjoy every byte.