A logo showing the text blog.marcnuri.com
Español
Home»JavaScript»JavaScript Event Loop Explained: Microtasks, Tasks and Node

Recent Posts

  • Fabric8 Kubernetes Client 8.0 is now available!
  • Fabric8 Kubernetes Client 7.9 is now available!
  • Eclipse JKube 1.20 is now available!
  • Project, Agent, Person: The Missing Axis of AI Coding Tools
  • Fabric8 Kubernetes Client 7.8 is now available!

Categories

  • Artificial Intelligence
  • Backend Development
  • Cloud Native
  • Engineering Insights
  • Frontend Development
  • JavaScript
  • Legacy
  • Operations
  • Personal
  • Pet projects
  • Quality Engineering
  • Tools

Archives

  • September 2026
  • July 2026
  • June 2026
  • May 2026
  • April 2026
  • March 2026
  • February 2026
  • January 2026
  • December 2025
  • October 2025
  • September 2025
  • July 2025
  • May 2025
  • April 2025
  • March 2025
  • February 2025
  • January 2025
  • December 2024
  • November 2024
  • August 2024
  • June 2024
  • May 2024
  • April 2024
  • March 2024
  • February 2024
  • January 2024
  • December 2023
  • November 2023
  • October 2023
  • September 2023
  • August 2023
  • July 2023
  • June 2023
  • May 2023
  • April 2023
  • March 2023
  • February 2023
  • January 2023
  • December 2022
  • November 2022
  • October 2022
  • September 2022
  • August 2022
  • July 2022
  • June 2022
  • May 2022
  • March 2022
  • February 2022
  • January 2022
  • December 2021
  • November 2021
  • October 2021
  • September 2021
  • August 2021
  • July 2021
  • January 2021
  • December 2020
  • November 2020
  • October 2020
  • September 2020
  • August 2020
  • July 2020
  • June 2020
  • May 2020
  • March 2020
  • February 2020
  • January 2020
  • December 2019
  • November 2019
  • October 2019
  • September 2019
  • July 2019
  • March 2019
  • November 2018
  • July 2018
  • June 2018
  • May 2018
  • April 2018
  • March 2018
  • February 2018
  • December 2017
  • October 2017
  • August 2017
  • July 2017
  • January 2017
  • May 2016
  • December 2015
  • November 2015
  • December 2014
  • November 2014
  • October 2014
  • March 2014
  • August 2012
  • February 2011
  • November 2008
  • June 2008
  • May 2008
  • April 2008
  • January 2008
  • November 2007
  • September 2007
  • August 2007
  • July 2007
  • June 2007
  • May 2007
  • April 2007
  • March 2007

JavaScript Event Loop Explained: Microtasks, Tasks and Node

2012-08-18 in JavaScript tagged JavaScript / Node / Concurrency by Marc Nuri | Last updated: 2026-10-17
Versión en Español

Introduction

⁠

JavaScript runs your code on a single thread. There is one call stack, and only one function executes at any given moment. Yet a browser tab keeps responding to clicks while it waits for a network request, and a single Node.js process serves thousands of concurrent connections.

How can a single-threaded language be so good at doing many things at once? The answer is the event loop, the mechanism that lets asynchronous JavaScript wait for timers, network responses, and user input without blocking.

Understanding the event loop explains most of the "weird" behavior you'll run into with asynchronous code: why setTimeout(fn, 0) doesn't run immediately, why a Promise callback runs before a timer that was scheduled first, why a long loop freezes the page, and why process.nextTick and setImmediate have such confusing names.

In this post, I'll explain how the JavaScript runtime is organized, walk through the event loop algorithm step by step, compare microtasks and macrotasks, and cover the specifics of the Node.js event loop. I'll finish with real examples from my own open-source projects, common pitfalls, debugging tips, and a few classic interview questions.

What is the JavaScript event loop?

⁠

The JavaScript event loop is the runtime mechanism that runs your code, then waits for queued work and processes it one item at a time. Whenever the call stack is empty, the event loop takes the next task from a queue, runs it to completion, then runs every pending microtask before moving on.

That's the whole idea. Everything else in this post is a detail of what "a task", "a microtask", and "the queue" mean, and of where the browser and Node.js differ.

The smallest example that shows the event loop at work:

first-example.js
console.log('1. Start');
setTimeout(() => console.log('3. Timeout callback'), 0);
console.log('2. End');
Output
1. Start
2. End
3. Timeout callback

Even with a delay of zero, the timeout callback runs last. setTimeout doesn't call the function; it asks the host environment to queue it once the timer expires. The event loop can only pick that callback up after the current script finishes and the call stack is empty.

The JavaScript runtime: call stack, heap, queues and host APIs

⁠

A common misconception is that the event loop is part of the JavaScript engine (V8, SpiderMonkey, JavaScriptCore). It isn't. The ECMAScript specification only defines jobs (the Promise jobs that browsers call microtasks) and says they run "when no other ECMAScript computation is currently in progress". The loop itself belongs to the host: the browser implements it following the HTML specification, and Node.js implements it on top of libuv.

The JavaScript runtime is made of these parts:

  • Call stack: where your code executes, one frame per function call.
  • Heap: the unstructured memory region where objects, closures, and arrays live.
  • Host APIs: timers, network, DOM events, and the file system, provided by the Web APIs in the browser and by C++ bindings backed by libuv in Node.js.
  • Task queues: callbacks that are ready to run, such as an expired timer, a click event, or a finished file read.
  • Microtask queue: higher-priority callbacks, mainly Promise reactions and await continuations.
  • The event loop: the coordinator that moves work from the queues to the call stack.

The JavaScript runtimeThe call stack schedules work with the host APIs, which queue callbacks in the task queues, while Promises and await queue microtasks. The event loop runs one task on the call stack, then every microtask, after each task.

🌐 Host: browser or Node.js

⚙️ JavaScript engine

schedules

callback ready

then, await

one task

all, after each task

runs

Call stack

Host APIs
timers, network, fs

Task queues
timers, I/O, events

Microtask queue
Promises, await

Event loop

The JavaScript Runtime

"JavaScript is single-threaded" is only true for your code. The runtime uses plenty of threads behind the scenes: the browser has network and compositor threads, Node.js has a thread pool for file system operations, and both engines run garbage collection and compilation in parallel. What you get is a single thread executing JavaScript per event loop, which is why you never need locks to update a variable.

Note

Web Workers in the browser and worker_threads in Node.js give you additional JavaScript threads. Each worker has its own call stack, heap, and event loop, and they communicate by passing messages.

The call stack

⁠

The call stack is a last-in, first-out structure that tracks where the program is. Each function call pushes a stack frame holding its arguments and local variables; each return pops it.

call-stack.js
function multiply(a, b) {
  return a * b;
}

function square(n) {
  return multiply(n, n);
}

function printSquare(n) {
  console.log(square(n));
}

printSquare(4);

When this script runs, the stack grows and shrinks like this:

  1. The script itself starts as the bottom frame.
  2. printSquare(4) is pushed.
  3. square(4) is pushed on top of it.
  4. multiply(4, 4) is pushed, returns 16, and is popped.
  5. square returns 16 and is popped.
  6. console.log(16) is pushed, prints 16, and is popped.
  7. printSquare returns, and finally the script frame is popped.

The stack is now empty, and that's exactly the moment the event loop is waiting for. This is also what an error's stack trace shows you: a snapshot of the frames on the call stack when the error was created.

Run to completion

⁠

Once a function starts running, nothing can interrupt it. No timer, click handler, or network callback runs until the call stack is empty again. This guarantee is called run to completion, and it's what makes JavaScript code easy to reason about: the state you read at the top of a function is still the state at the bottom, unless your own code changed it.

The flip side is that a slow function blocks everything else:

blocking.js
const start = Date.now();
setTimeout(() => {
  console.log(`Timeout fired after ${Date.now() - start} ms`);
}, 0);

// Simulate 1 second of CPU-bound work
while (Date.now() - start < 1000) {
  // busy wait
}
console.log('Synchronous work done');
Output (timings vary)
Synchronous work done
Timeout fired after 1004 ms

The timer expired after a few milliseconds, but its callback had to wait a full second for the loop to finish. In a browser, that same second means no clicks, no scrolling, and no repaints.

Stack overflow

⁠

The stack has a limited size. A recursive function without a base case keeps pushing frames until the engine gives up:

stack-overflow.js
function countDown(n) {
  return countDown(n - 1); // No base case
}

try {
  countDown(10);
} catch (error) {
  console.log(error.name, error.message);
}
Output (Node.js and Chrome)
RangeError Maximum call stack size exceeded

V8 (Chrome, Node.js) and JavaScriptCore (Safari) throw a RangeError with this message, while Firefox throws InternalError: too much recursion. How deep you can go isn't standardized; it depends on the engine, the platform, and the size of each frame.

How the event loop works, step by step

⁠

The HTML specification describes the browser event loop as an infinite loop. Simplified, every iteration does this:

  1. Pick a task: choose a task queue that has a runnable task and take its oldest task.
  2. Run the task to completion: execute its callback until the call stack is empty.
  3. Perform a microtask checkpoint: run every microtask in the microtask queue, including the ones queued by other microtasks, until the queue is empty.
  4. Update the rendering, if it's time: run requestAnimationFrame callbacks, recalculate styles and layout, and paint.
  5. Wait: if there's no task to run, sleep until a new one arrives (running requestIdleCallback callbacks while idle).
  6. Repeat.

One browser event loop iterationWhen a task is ready, the event loop runs the oldest one to completion, then runs microtasks until their queue is empty, and, if it's time for a new frame, runs the requestAnimationFrame callbacks and paints. When no task is ready, it waits, running idle callbacks.

No

Yes

No

Yes

Yes

No

Next iteration

Any task
ready?

Wait for a task
(idle callbacks run)

Pick the oldest task
from a task queue

Run it to completion
until the call stack is empty

Microtask queue
empty?

Run the oldest microtask

Time for
a new frame?

requestAnimationFrame callbacks

Style, layout, paint

One Browser Event Loop Iteration

A few details are worth knowing because simplified explanations usually skip them.

There isn't a single callback queue. Every task comes from a task source, such as timers, user interaction, networking, or DOM manipulation, and a browser keeps several task queues, each fed by one or more of those sources. Tasks from the same source always run in order, but the browser may choose which queue to serve next. The specification itself gives the example of one queue for mouse and keyboard events, served ahead of another queue for everything else, so a click may be handled before a timer that expired earlier.

Rendering doesn't happen after every task. The browser renders only when there's a rendering opportunity, typically aligned with the display's refresh rate (about every 16.7 ms at 60 Hz), and usually not at all for a hidden tab. Many tasks can run between two frames, and a frame is skipped when there's nothing visible to update. Rendering happens between tasks, never in the middle of one.

Microtasks vs macrotasks

⁠

You'll find the term macrotask in most articles and interview questions, but the HTML specification never uses it. The specification simply calls them tasks, as opposed to microtasks. I'll use "task" from here on; just remember that both words mean the same thing.

The difference between them is when they run:

  • Tasks run one per event loop iteration, with a chance to render and run other tasks in between.
  • Microtasks run as soon as the call stack is empty, and the queue is drained completely before anything else happens: no other task and no rendering.
APIRuns asEnvironment
setTimeout, setIntervalTaskBrowser and Node.js
UI events (click, keydown), postMessageTaskBrowser
I/O callbacks (fs, sockets)Task (poll phase)Node.js
setImmediateTask (check phase)Node.js
Promise.then, catch, finallyMicrotaskBrowser and Node.js
await continuationsMicrotaskBrowser and Node.js
queueMicrotaskMicrotaskBrowser and Node.js
MutationObserverMicrotaskBrowser
process.nextTickNext tick queue, before microtasksNode.js
requestAnimationFrameRendering step, before paintBrowser

The Node.js rows are explained in the Node.js section. Here's the priority in action:

microtasks-vs-tasks.js
console.log('script start');

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

Promise.resolve()
  .then(() => console.log('promise 1'))
  .then(() => console.log('promise 2'));

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

console.log('script end');
Output (Chrome 154 and Node.js 26)
script start
script end
promise 1
queueMicrotask
promise 2
setTimeout

The synchronous code runs first, as the script is the current task. Then the microtask queue holds promise 1 and queueMicrotask, in the order they were queued. Running promise 1 resolves the next promise in the chain, which queues promise 2 at the end of the microtask queue, so it runs before the checkpoint ends. Only then can the event loop pick the timer task.

Microtasks don't even wait for the end of the task. A microtask checkpoint happens whenever the JavaScript call stack becomes empty. If you click a button with two click listeners, the browser calls each listener separately, and the microtasks queued by the first listener run before the second listener starts. If your code calls button.click() instead, both listeners run inside your call, the stack never empties in between, and their microtasks wait until your script finishes.

How async/await uses the microtask queue

⁠

async/await is syntax built on top of Promises, so it follows the same rules. An async function runs synchronously until its first await. At that point, the function is suspended, and the rest of its body is queued as a microtask once the awaited value settles.

async-await.js
async function loadUser() {
  console.log('loadUser: start');
  await null;
  console.log('loadUser: after await');
}

console.log('before');
loadUser();
console.log('after');
Output
before
loadUser: start
after
loadUser: after await

The function behaves as if it had been written with a then callback:

async-await-desugared.js
function loadUser() {
  console.log('loadUser: start');
  return Promise.resolve(null).then(() => {
    console.log('loadUser: after await');
  });
}

This answers a frequent question: does await block? It blocks the async function, which doesn't continue until the value is ready, but it never blocks the thread. While the function is suspended, the event loop keeps running other tasks.

Awaiting a native Promise costs a single microtask tick in current engines. Before V8 7.2 (Chrome 72, Node.js 12), it took at least three ticks; the V8 team's write-up explains the specification change that removed the extra ones. Interview puzzles written before 2019 may give a different answer for code that mixes await and then for that reason.

Microtask starvation

⁠

Because the microtask queue is drained completely, a microtask that keeps queueing more microtasks never lets the event loop move on:

microtask-starvation.js
const start = performance.now();
setTimeout(() => {
  console.log(`setTimeout ran after ${Math.round(performance.now() - start)} ms`);
}, 0);

const spin = () => {
  // Keep queueing microtasks for 200 ms
  if (performance.now() - start < 200) {
    queueMicrotask(spin);
  }
};
queueMicrotask(spin);
Output (timings vary)
setTimeout ran after 200 ms

I had to bound the loop to 200 ms to make it finish. Without that condition, the timer would never fire and, in a browser, the page would never repaint. A recursive Promise chain is just as dangerous as a while (true) loop.

setTimeout(fn, 0) and timer clamping

⁠

The delay you pass to setTimeout is a minimum, not a schedule. When the timer expires, the callback is queued as a task, and it still has to wait for the current task, all the microtasks, possibly a rendering update, and any other task the browser decides to run first.

On top of that, both environments adjust small delays:

  • Browsers clamp nested timers: once timers are nested more than five levels deep, the HTML specification turns any delay under 4 ms into 4 ms, so badly written recursive timers can't burn the CPU.
  • Background tabs are throttled: Chrome runs the timers of a hidden page at most once per second, and after five minutes in the background it can limit chained timers to once per minute.
  • Node.js has a 1 ms minimum: any delay under 1 ms (or over 2147483647 ms, which overflows a 32-bit integer) becomes 1 ms, and there's no 4 ms clamp.

You can see the clamping by measuring the gap between nested timers:

nested-timers.js
const gaps = [];
let last = performance.now();
const tick = () => {
  const now = performance.now();
  gaps.push((now - last).toFixed(1));
  last = now;
  if (gaps.length < 10) {
    setTimeout(tick, 0);
  } else {
    console.log(gaps.join(', '));
  }
};
setTimeout(tick, 0);
Output (Chrome 154)
0.1, 0.1, 0.0, 0.0, 0.0, 0.0, 4.5, 4.6, 4.6, 4.1

The first six callbacks ran back to back; from the seventh onward, each waited at least 4 ms.

So why use setTimeout(fn, 0) at all? It's the classic way to defer work to a later task: let the current code finish, give the browser a chance to process pending events (and to repaint, if a frame is due), and continue afterward. It's also how you split a long job into chunks so the page stays responsive, as you'll see in the pitfalls section. If you only need to run something after the current code but before anything else, use queueMicrotask instead.

Rendering and requestAnimationFrame

⁠

The browser event loop also drives rendering, which is the main difference from the Node.js event loop. When it's time for a new frame, the browser runs these steps in order:

  1. Resize and scroll events.
  2. Media query and animation updates.
  3. requestAnimationFrame callbacks.
  4. Style and layout calculation, including ResizeObserver callbacks.
  5. IntersectionObserver callbacks.
  6. Paint.

requestAnimationFrame (rAF) lets you run code right before the next paint, which makes it the right tool for animations:

animation.js
const box = document.querySelector('.box');
let start;

function step(timestamp) {
  start ??= timestamp;
  const elapsed = timestamp - start;
  box.style.transform = `translateX(${Math.min(0.1 * elapsed, 200)}px)`;
  if (elapsed < 2000) {
    requestAnimationFrame(step); // Runs in the next frame
  }
}

requestAnimationFrame(step);

Some details about rAF:

  • A callback scheduled from inside a rAF callback runs in the next frame, so the recursive pattern above runs exactly once per frame.
  • Microtasks queued from a rAF callback run right after that callback, before the next one.
  • The browser pauses or throttles rAF in hidden tabs, so an animation doesn't waste battery when nobody is looking.
  • Use the timestamp argument to compute progress instead of assuming a fixed frame rate: displays refresh at 60, 120, or 144 Hz.

Animating with setTimeout(step, 16) instead isn't synchronized with the display. Some frames get two updates and others none, which shows up as jank. And don't rely on the order between a setTimeout(fn, 0) and a requestAnimationFrame callback scheduled at the same time: it depends on where the browser is in its frame. Browsers haven't always agreed on it either; Safari ran rAF callbacks after painting instead of before until WebKit fixed it in 2019.

The Node.js event loop

⁠

Node.js doesn't render anything, and its event loop is organized differently. It's provided by libuv, a C library that wraps the operating system's asynchronous I/O mechanisms (epoll on Linux, kqueue on macOS, IOCP on Windows). Instead of a set of task queues the loop chooses from, Node.js cycles through a fixed sequence of phases, each with its own queue of callbacks.

Node.js event loop phases

⁠

Since Node.js 20.3, each iteration of the loop runs these phases in this order (the note after the diagram explains what changed):

PhaseWhat runs there
Pending callbacksI/O callbacks deferred from the previous iteration, such as some TCP errors
Idle, prepareInternal use only
PollNew I/O events and their callbacks; the loop blocks here when there's nothing else to do
ChecksetImmediate callbacks
Close callbacksClose events, such as socket.on('close', ...)
TimersExpired setTimeout and setInterval callbacks (also once before the first iteration)

The Node.js event loop, Node.js 20.3 and laterAfter the main script and a first timers phase, each iteration runs the pending callbacks, idle and prepare, poll, check, close callbacks, and timers phases, and starts over while timers, handles, or requests are pending. Otherwise, the process exits.

Yes

No

Run the main script

⏱️ timers
once, before the loop starts

pending callbacks

idle, prepare

📥 poll
wait for and run I/O callbacks

✅ check
setImmediate callbacks

close callbacks

⏱️ timers
setTimeout, setInterval

Pending timers,
handles or requests?

Process exits

The Node.js Event Loop (Node.js 20.3+)

Note

Starting with libuv 1.45 (Node.js 20.3), timers run at the end of each iteration, after the poll, check, and close phases. In earlier versions they ran at the beginning, before polling. libuv still runs timers once before entering the loop, so the cycle looks the same, but the change can affect the relative order of setImmediate and timer callbacks in some scenarios. Many diagrams, including the one in the Node.js documentation, still show timers as the first phase.

The poll phase is where Node.js spends most of its idle time. If there are no setImmediate callbacks waiting, it blocks waiting for I/O, but only until the soonest timer is due. When nothing is left (no timers, no open sockets, no pending requests), the loop ends and the process exits.

Between every callback, in every phase, Node.js drains two queues: first the process.nextTick queue, then the Promise microtask queue.

process.nextTick vs queueMicrotask

⁠

process.nextTick predates Promises in Node.js. Its callbacks go to a dedicated queue that's processed after the current operation and before the Promise microtask queue. Despite the name, it doesn't wait for the loop to move on: its callbacks run as soon as the current operation finishes. setImmediate is the one that waits, until the next check phase, and the Node.js documentation itself admits that the two names should be swapped.

node-order.js
setTimeout(() => console.log('setTimeout'), 0);
setImmediate(() => console.log('setImmediate'));
Promise.resolve().then(() => console.log('promise'));
queueMicrotask(() => console.log('queueMicrotask'));
process.nextTick(() => console.log('nextTick'));
console.log('sync');
Output (CommonJS, node node-order.js)
sync
nextTick
promise
queueMicrotask
setTimeout
setImmediate

Save the same code as node-order.mjs and the order of the microtasks changes:

Output (ES module, node node-order.mjs)
sync
promise
queueMicrotask
nextTick
setTimeout
setImmediate

An ES module is evaluated as part of the microtask queue. When the module body finishes, Node.js is still in the middle of draining microtasks, so the Promise callbacks run first, and the nextTick queue is only processed after the microtask queue is empty.

Recursive process.nextTick calls starve the event loop exactly like recursive microtasks do, preventing it from ever reaching the poll phase. Since Node.js 22.7 (and 20.18), the documentation marks process.nextTick as legacy and recommends queueMicrotask instead, which behaves the same in browsers and Node.js.

setImmediate vs setTimeout

⁠

In the previous example, setTimeout happened to run before setImmediate, but you can't rely on that. From the main module, their order is non-deterministic: it depends on how much time has passed when the loop starts. In my tests on Node.js 26, a script that only scheduled the two ran setImmediate first 96 times out of 100, while the script above, which does a bit more work before the loop starts, ran the timer first every time.

Inside an I/O callback, however, the order is guaranteed:

set-immediate-io.js
const fs = require('node:fs');

fs.readFile(__filename, () => {
  setTimeout(() => console.log('setTimeout'), 0);
  setImmediate(() => console.log('setImmediate'));
});
Output
setImmediate
setTimeout

The readFile callback runs in the poll phase. The next phase is check, so the immediate always runs before the loop gets back to the timers. Use setImmediate when you want to run something after the pending I/O callbacks, for example to split a long job without starving the server's connections.

The libuv thread pool

⁠

Network I/O in Node.js uses the operating system's non-blocking APIs directly on the event loop thread. Some operations have no non-blocking equivalent, so libuv runs them on a thread pool and queues their callbacks when they finish: the asynchronous file system APIs (except fs.watch, which relies on the operating system's change notifications), dns.lookup, some crypto functions (pbkdf2, scrypt, randomBytes, generateKeyPair), and the asynchronous zlib APIs, which compress and decompress.

The pool has 4 threads by default, which you can see with five CPU-intensive hashes:

thread-pool.js
const crypto = require('node:crypto');

const start = Date.now();
for (let i = 1; i <= 5; i++) {
  crypto.pbkdf2('secret', 'salt', 500_000, 64, 'sha512', () => {
    console.log(`hash ${i} done after ${Date.now() - start} ms`);
  });
}
Output (timings vary)
hash 1 done after 125 ms
hash 2 done after 132 ms
hash 4 done after 134 ms
hash 3 done after 143 ms
hash 5 done after 234 ms

The first four hashes run in parallel and finish at about the same time, in a different order on every run. The fifth one has to wait for a free thread, so it takes twice as long. You can resize the pool with the UV_THREADPOOL_SIZE environment variable (up to 1024), as long as you set it before the pool is first used.

The event loop in a real app: ElectronIM

⁠

ElectronIM is a free and open source instant messaging client I maintain that combines your messaging web applications (Slack, Telegram, WhatsApp, or any other) into a single Electron desktop window. Electron makes the event loop especially visible because an app is made of several processes: one main process running Node.js, and a renderer process for each window or web view, each running Chromium's browser event loop. They communicate through IPC (inter-process communication) messages, which arrive as tasks in the receiving process's event loop.

When the order of tasks is the bug

⁠

ElectronIM provides its own spell checker for the services. After upgrading to Electron 44, typing in some services started crashing their renderer and leaving a blank page. Blink, Chromium's rendering engine, had started cancelling its in-flight spell check requests:

  1. Blink asks ElectronIM to check the words of request A.
  2. The user keeps typing, so Blink cancels A and issues request B.
  3. The answer to A arrives, and Electron's spell check client, which tracks a single pending request, applies it to B, so the answer to B later finds nothing pending and crashes the renderer.

The pull request that fixed it is all about task ordering. The spell check function only answers the most recent request, and it answers from a requestIdleCallback:

ElectronIM: src/service-manager/preload.spell-check.js
let pendingRequest = null;

const spellCheckFunction = (words, callback) => {
  const request = {};
  pendingRequest = request;
  const answer = misspelled => requestIdleCallback(() => {
    if (pendingRequest !== request) {
      return;
    }
    pendingRequest = null;
    try {
      callback(misspelled);
    } catch (error) {
      console.error('Spell check callback failed', error);
    }
  });
  ipcRenderer.invoke(APP_EVENTS.dictionaryGetMisspelled, words)
    .then(answer, () => answer([]));
};

Electron delivers each new request to this function in a regular task. So when an older answer is ready, a newer request may already be queued behind it. An idle callback only runs when the event loop has no pending tasks, which means that queued delivery runs first and replaces pendingRequest, and the stale answer is dropped. The spell check preload script deliberately passes no timeout to requestIdleCallback: a timed-out idle callback is run as a regular task, and it could beat the delivery.

The dictionaries themselves are loaded in a hidden renderer process (src/spell-check/index.js), which acts as a worker with its own event loop. Loading Hunspell dictionaries takes a while, and doing it in the main process would freeze every window of the app.

Asynchronous and synchronous IPC

⁠

Electron offers both asynchronous IPC (ipcRenderer.send and ipcRenderer.invoke, which returns a Promise) and synchronous IPC (ipcRenderer.sendSync). sendSync blocks the renderer's event loop until the main process replies, so you should avoid it.

ElectronIM uses it in a few places, and in one of them there's no alternative. It replaces the web Notification constructor so the user can disable notifications per service, and a constructor must return synchronously:

ElectronIM: src/service-manager/preload.notification-shim.js
const canNotify = () => ipcRenderer.sendSync(APP_EVENTS.canNotify, globalThis.tabId);

Notification = function() {
  if (canNotify()) {
    const delegate = new NativeNotification(...arguments);
    // ...
  }
  return {};
};

Timers that don't keep the app alive

⁠

The main process checks for new versions every 30 minutes:

ElectronIM: src/update/index.js
const checkForUpdatesInit = async () => {
  await checkForUpdates();
  setInterval(checkForUpdates, 1000 * 60 * 30).unref();
};

In plain Node.js, an active timer keeps the event loop, and the process, alive. Calling unref() tells Node.js that this interval alone isn't a reason to keep running. Electron's main process ends with app.quit() or app.exit() regardless, but the unref() keeps the module from holding a plain Node.js process open, such as the one running its unit tests.

Chained timeouts in a real app: Isotope

⁠

Isotope, the webmail client I built with a React front end, shows another timer pattern. Each refresh poll reloads the folders and streams the folder's messages over Server-Sent Events, whose message callbacks are queued as tasks. The next poll is scheduled with setTimeout only after await Promise.all(...) settles, instead of with a setInterval. So two refreshes can never overlap, no matter how slow the server is.

Common pitfalls

⁠

Most event loop problems come down to work that takes too long on the one thread that runs your code, or to callbacks that never go away.

Blocking the event loop

⁠

Since one thread runs all your JavaScript, any long synchronous operation blocks everything else. In a browser, the page freezes; in Node.js, every other request waits. The usual suspects are:

  • CPU-heavy loops: sorting, filtering, or transforming large arrays, image processing, or cryptography in JavaScript.
  • Huge JSON payloads: the Node.js documentation measured JSON.parse taking 1.3 seconds for a 50 MB string.
  • Synchronous APIs: fs.readFileSync, crypto.pbkdf2Sync, zlib.gzipSync, or child_process.execSync in a server.
  • Catastrophic regular expressions (ReDoS): a pattern with nested quantifiers like /(a+)*$/ can take exponential time on a crafted input.

Warning

In a Node.js server, a single request that triggers a slow regular expression or parses a huge payload blocks every other client. Validate input sizes before parsing, and avoid nested quantifiers in regular expressions that process user input.

Splitting long tasks

⁠

If the work has to happen on the main thread, split it into chunks and yield to the event loop between them, so other tasks and rendering can run:

chunked-work.js
const yieldToEventLoop = () =>
  globalThis.scheduler?.yield
    ? globalThis.scheduler.yield()
    : new Promise(resolve => setTimeout(resolve, 0));

async function processAll(items, processItem) {
  let lastYield = performance.now();
  for (const item of items) {
    processItem(item);
    if (performance.now() - lastYield > 50) {
      await yieldToEventLoop(); // let other tasks (and rendering) run
      lastYield = performance.now();
    }
  }
}

scheduler.yield() is the modern way to yield in the browser: its continuation runs ahead of other tasks of the same priority, so your job doesn't lose its place in line. It's available in Chrome 129 and Firefox 142, but not in Safari yet, hence the setTimeout fallback. Node.js has no scheduler global, so the helper falls back to setTimeout there; prefer await setImmediate() from node:timers/promises, which lets pending I/O callbacks run first.

Moving work off the main thread

⁠

Splitting work keeps the app responsive, but it doesn't make the work faster. For truly CPU-bound tasks, use another thread: a Web Worker in the browser, or worker_threads in Node.js.

main.mjs
import {Worker} from 'node:worker_threads';

const worker = new Worker(new URL('./fibonacci.worker.mjs', import.meta.url), {workerData: 40});
worker.on('message', result => console.log(`fibonacci(40) = ${result}`));

const tick = setInterval(() => console.log('main thread is still responsive'), 250);
worker.on('exit', () => clearInterval(tick));
fibonacci.worker.mjs
import {parentPort, workerData} from 'node:worker_threads';

const fibonacci = n => (n < 2 ? n : fibonacci(n - 1) + fibonacci(n - 2));
parentPort.postMessage(fibonacci(workerData));
Output (timings vary)
main thread is still responsive
main thread is still responsive
fibonacci(40) = 102334155

The worker computes on its own thread with its own event loop, and the result arrives in the main thread as a message task. Workers don't share memory with the main thread (except through SharedArrayBuffer), so there's no shared state to protect. If you're curious about how other languages approach this, compare it with Go's goroutines and channels or Java's virtual threads, which multiplex many lightweight threads instead.

Timer and listener leaks

⁠

An active setInterval keeps a reference to its callback, and the callback keeps references to everything its closure captures. If you never clear it, none of that memory can be collected, and in Node.js the interval also keeps the process alive.

polling.js
function startPolling(refresh) {
  const interval = setInterval(refresh, 1000);
  return () => clearInterval(interval); // Call this when you're done
}

Always pair a setInterval or addEventListener with its cleanup when the component, connection, or page that needs it goes away. For timers that shouldn't keep a Node.js process running, call unref() as ElectronIM does.

Detecting a blocked event loop

⁠

In the browser, the Performance panel of Chrome DevTools records everything that runs on the main thread. Any task that takes longer than 50 ms is a long task: DevTools marks it with a red triangle and shades the part beyond 50 ms, which is the part that delays the response to user input. Expand it to see which functions took the time.

To detect slow frames in production, the Long Animation Frames API (available in Chromium-based browsers since Chrome 123) reports every frame delayed by more than 50 ms, with the scripts that caused it:

long-animation-frames.js
new PerformanceObserver(list => {
  for (const entry of list.getEntries()) {
    console.log(`Long frame: ${Math.round(entry.duration)} ms, blocking ${Math.round(entry.blockingDuration)} ms`);
  }
}).observe({type: 'long-animation-frame', buffered: true});

In Node.js, measure the event loop delay: how late timers fire compared to when they were due. The built-in perf_hooks module provides a histogram for it:

event-loop-delay.js
const {monitorEventLoopDelay} = require('node:perf_hooks');

const histogram = monitorEventLoopDelay({resolution: 10});
histogram.enable();

const block = ms => {
  const end = Date.now() + ms;
  while (Date.now() < end) {
    // busy wait
  }
};
const work = setInterval(() => block(200), 500);

setTimeout(() => {
  clearInterval(work);
  histogram.disable();
  console.log(`p50 delay: ${(histogram.percentile(50) / 1e6).toFixed(1)} ms`);
  console.log(`p99 delay: ${(histogram.percentile(99) / 1e6).toFixed(1)} ms`);
  console.log(`max delay: ${(histogram.max / 1e6).toFixed(1)} ms`);
}, 3000);
Output (timings vary)
p50 delay: 11.0 ms
p99 delay: 205.5 ms
max delay: 210.6 ms

The median looks fine, but the p99 reveals the 200 ms blocks. Exporting these percentiles to your monitoring system is one of the best health checks for a Node.js service. performance.eventLoopUtilization() complements it by reporting the fraction of time the loop was busy, and node --cpu-prof writes a CPU profile you can open in Chrome DevTools to find the function responsible.

Event loop interview questions

⁠

Event loop puzzles are a classic in JavaScript interviews. Try to predict the output before you open the answer.

Puzzle 1: async functions, Promises and timers

⁠
puzzle-1.js
async function first() {
  console.log('first start');
  await second();
  console.log('first end');
}

async function second() {
  console.log('second');
}

console.log('script start');
setTimeout(() => console.log('setTimeout'), 0);
first();
new Promise(resolve => {
  console.log('promise executor');
  resolve();
}).then(() => console.log('promise then'));
console.log('script end');
Show the answer
Output
script start
first start
second
promise executor
script end
first end
promise then
setTimeout

second() and the Promise executor run synchronously. second() returns an already fulfilled promise, so await queues first end immediately, during the call to first(), before the executor runs and promise then is queued. The timer task runs last.

Puzzle 2: timers inside microtasks and microtasks inside timers

⁠
puzzle-2.js
setTimeout(() => {
  console.log('timeout 1');
  Promise.resolve().then(() => console.log('promise inside timeout 1'));
}, 0);

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

Promise.resolve().then(() => {
  console.log('promise 1');
  setTimeout(() => console.log('timeout inside promise 1'), 0);
});
Show the answer
Output
promise 1
timeout 1
promise inside timeout 1
timeout 2
timeout inside promise 1

The microtask queued in timeout 1 runs before timeout 2, because a microtask checkpoint follows every task. The timer created inside promise 1 is queued after the two existing timers. Node.js has produced the same output as browsers since version 11; earlier versions ran all expired timers before processing any microtask.

Puzzle 3: var in a loop

⁠
puzzle-3.js
for (var i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 0);
}
Show the answer
Output
3
3
3

The callbacks only run after the loop has finished, and all three share the same function-scoped i, which is 3 by then. Declaring the variable with let creates a new binding per iteration and prints 0, 1, 2. Before let existed, the standard fix was to capture the value in an Immediately Invoked Function Expression (IIFE).

FAQ

⁠

Why is JavaScript single-threaded?

⁠

JavaScript was designed to script web pages, where code manipulates a shared DOM in response to user events. A single thread with run-to-completion semantics avoids locks, race conditions, and inconsistent DOM states. Concurrency comes from the event loop and asynchronous host APIs, and parallelism from workers.

Is setTimeout(fn, 0) executed immediately?

⁠

No. It queues the callback as a task that runs after the current code, all pending microtasks, and possibly other tasks and a rendering update. Browsers clamp nested timers to 4 ms, and Node.js uses a 1 ms minimum.

What runs first, a microtask or a macrotask?

⁠

Microtasks. After every task, and whenever the call stack empties, the event loop drains the entire microtask queue before it runs the next task or renders.

Are the browser and Node.js event loops the same?

⁠

The rules for the call stack, tasks, and microtasks are the same, but the structure differs. The browser chooses between several task queues and integrates rendering; Node.js cycles through libuv's phases and adds process.nextTick and setImmediate.

Does async/await make code run in parallel?

⁠

No. async/await makes asynchronous code easier to write, but everything still runs on the same thread. To run CPU-bound code in parallel, use Web Workers or worker_threads.

Conclusion

⁠

The JavaScript event loop is what turns a single-threaded language into a capable asynchronous platform. Your code runs to completion on one call stack, and everything else waits in queues until the stack is empty.

The mental model to keep is simple: one task, then all the microtasks, then maybe a render, repeat. Synchronous code runs first, Promise callbacks and await continuations run as soon as the stack empties, and timers, I/O, and events run one task at a time. In Node.js, the tasks are organized in phases, process.nextTick jumps ahead of Promises, and setImmediate runs right after I/O.

With that model, ordering puzzles stop being puzzles, and you can recognize the real problems: long tasks that block the loop, microtasks that starve it, and timers that leak. For further reading, the HTML specification's event loop section, the Node.js event loop guide, and Jake Archibald's Tasks, microtasks, queues and schedules are the best next steps.

You might also like

⁠
  • What is an Immediately Invoked Function Expression (IIFE)?
  • What are Barrel Exports in JavaScript and TypeScript?
  • Go Concurrency Patterns: Goroutines and Channels
  • Java Virtual Threads Complete Guide (Project Loom)
Twitter iconFacebook iconLinkedIn iconPinterest iconEmail icon

Post navigation
What is an Immediately Invoked Function Expression (IIFE)?What is a Java Heap dump?
© 2007 - 2026 Marc Nuri