JavaScript Event Loop Explained: Microtasks, Tasks and Node
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:
console.log('1. Start');
setTimeout(() => console.log('3. Timeout callback'), 0);
console.log('2. End');1. Start
2. End
3. Timeout callbackEven 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
awaitcontinuations. - The event loop: the coordinator that moves work from the queues to the call stack.
"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.
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:
- The script itself starts as the bottom frame.
printSquare(4)is pushed.square(4)is pushed on top of it.multiply(4, 4)is pushed, returns16, and is popped.squarereturns16and is popped.console.log(16)is pushed, prints16, and is popped.printSquarereturns, 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:
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');Synchronous work done
Timeout fired after 1004 msThe 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:
function countDown(n) {
return countDown(n - 1); // No base case
}
try {
countDown(10);
} catch (error) {
console.log(error.name, error.message);
}RangeError Maximum call stack size exceededV8 (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:
- Pick a task: choose a task queue that has a runnable task and take its oldest task.
- Run the task to completion: execute its callback until the call stack is empty.
- Perform a microtask checkpoint: run every microtask in the microtask queue, including the ones queued by other microtasks, until the queue is empty.
- Update the rendering, if it's time: run
requestAnimationFramecallbacks, recalculate styles and layout, and paint. - Wait: if there's no task to run, sleep until a new one arrives (running
requestIdleCallbackcallbacks while idle). - Repeat.
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.
| API | Runs as | Environment |
|---|---|---|
setTimeout, setInterval | Task | Browser and Node.js |
UI events (click, keydown), postMessage | Task | Browser |
I/O callbacks (fs, sockets) | Task (poll phase) | Node.js |
setImmediate | Task (check phase) | Node.js |
Promise.then, catch, finally | Microtask | Browser and Node.js |
await continuations | Microtask | Browser and Node.js |
queueMicrotask | Microtask | Browser and Node.js |
MutationObserver | Microtask | Browser |
process.nextTick | Next tick queue, before microtasks | Node.js |
requestAnimationFrame | Rendering step, before paint | Browser |
The Node.js rows are explained in the Node.js section. Here's the priority in action:
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');script start
script end
promise 1
queueMicrotask
promise 2
setTimeoutThe 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 function loadUser() {
console.log('loadUser: start');
await null;
console.log('loadUser: after await');
}
console.log('before');
loadUser();
console.log('after');before
loadUser: start
after
loadUser: after awaitThe function behaves as if it had been written with a then callback:
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:
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);setTimeout ran after 200 msI 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:
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);0.1, 0.1, 0.0, 0.0, 0.0, 0.0, 4.5, 4.6, 4.6, 4.1The 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:
- Resize and scroll events.
- Media query and animation updates.
requestAnimationFramecallbacks.- Style and layout calculation, including
ResizeObservercallbacks. IntersectionObservercallbacks.- Paint.
requestAnimationFrame (rAF) lets you run code right before the next paint, which makes it the right tool for animations:
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
timestampargument 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):
| Phase | What runs there |
|---|---|
| Pending callbacks | I/O callbacks deferred from the previous iteration, such as some TCP errors |
| Idle, prepare | Internal use only |
| Poll | New I/O events and their callbacks; the loop blocks here when there's nothing else to do |
| Check | setImmediate callbacks |
| Close callbacks | Close events, such as socket.on('close', ...) |
| Timers | Expired setTimeout and setInterval callbacks (also once before the first iteration) |
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.
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');sync
nextTick
promise
queueMicrotask
setTimeout
setImmediateSave the same code as node-order.mjs and the order of the microtasks changes:
sync
promise
queueMicrotask
nextTick
setTimeout
setImmediateAn 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:
const fs = require('node:fs');
fs.readFile(__filename, () => {
setTimeout(() => console.log('setTimeout'), 0);
setImmediate(() => console.log('setImmediate'));
});setImmediate
setTimeoutThe 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:
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`);
});
}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 msThe 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:
- Blink asks ElectronIM to check the words of request A.
- The user keeps typing, so Blink cancels A and issues request B.
- 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:
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:
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:
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.parsetaking 1.3 seconds for a 50 MB string. - Synchronous APIs:
fs.readFileSync,crypto.pbkdf2Sync,zlib.gzipSync, orchild_process.execSyncin 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:
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.
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));import {parentPort, workerData} from 'node:worker_threads';
const fibonacci = n => (n < 2 ? n : fibonacci(n - 1) + fibonacci(n - 2));
parentPort.postMessage(fibonacci(workerData));main thread is still responsive
main thread is still responsive
fibonacci(40) = 102334155The 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.
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:
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:
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);p50 delay: 11.0 ms
p99 delay: 205.5 ms
max delay: 210.6 msThe 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
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
script start
first start
second
promise executor
script end
first end
promise then
setTimeoutsecond() 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
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
promise 1
timeout 1
promise inside timeout 1
timeout 2
timeout inside promise 1The 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
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0);
}Show the answer
3
3
3The 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.
