Skip to main content

Event loop — one thread juggling wait time

"What is JavaScript doing while waiting for a communication result?" Answering "waiting on another thread" makes several phenomena unexplainable. Why does one heavy computation freeze the button? Why does setTimeout(fn, 0) fire much later than "now"? Why does the line after await run after its caller's continuation?

All answers come from the same mechanism. On one event loop, only one thread runs JavaScript, processing queued work one at a time in order.

What you'll learn in this chapter​

  • Why one thread can juggle wait time
  • The difference between tasks and microtasks and their execution order
  • When the continuation after await runs
  • Why setTimeout(fn, 0) doesn't mean "immediately"
  • How a long task blocks painting and input
  • Three directions for offloading heavy processing
Prerequisites

Any experience writing Promise and async / await syntax is enough. Syntax itself is in JavaScript Guide — Asynchronous processing. The same phenomena viewed from the metric side are in Core Web Vitals.

What this chapter does not cover​

TopicSee
Promise / async / await syntax and using Promise.allJavaScript Guide — Asynchronous processing
Narrowing causes of poor INPCore Web Vitals
Running on separate threads or processesProcesses and threads
Handling failed async processingError handling / Retries and idempotency

While waiting, nothing is happening​

Pin this first: the wait time itself is outside JavaScript.

Calling fetch, the actual communication is handled by the browser (or Node.js) internals. The JavaScript side only registers "call this function when done" and moves to the next line. When communication finishes, internal machinery queues the registered function.

So the concurrent-appearance comes from the waiting partner being outside JavaScript. On one event loop, no more than one line of JavaScript runs at any moment. Explicitly starting another thread changes the story (Processes and threads).

Repeating "take one when free" endlessly is the event loop.

Tasks and microtasks​

There isn't just one queue. There are tasks and microtasks — two systems, treated differently.

HTML Standard's event loop processing model defines this cycle:

  1. Choose one task from a task queue and run its steps as oldestTask
  2. When done, Perform a microtask checkpoint

(HTML Standard — 8.1.7.3 Processing model)

And "microtask checkpoint" is defined as:

While the event loop's microtask queue is not empty: Let oldestMicrotask be the result of dequeuing from the event loop's microtask queue... Run oldestMicrotask.

— HTML Standard — 8.1.7.3 Processing model

The key is "while not empty". One task per iteration, but microtasks flow until the queue is empty. Queuing a new microtask inside a microtask still runs it in the same checkpoint.

QueuedAmount processed per iteration
TaskssetTimeout / setInterval callbacks, event handlers, received message processingJust one
MicrotasksPromise then / catch / finally, await continuations, queueMicrotaskAll until empty

Execution order:

console.log('1 sync');

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

Promise.resolve().then(() => {
console.log('3 microtask');
Promise.resolve().then(() => console.log('3.5 microtask queued inside a microtask'));
});

console.log('2 sync');
1 sync
2 sync
3 microtask
3.5 microtask queued inside a microtask
4 task

Synchronous code finishes, then microtasks flow, then tasks. Microtasks queued during a checkpoint interrupt before the next task — direct consequence of the definition above.

This property has a cost. Continuously queuing microtasks inside microtasks prevents the checkpoint while from ending. Next task and next paint never come; the page stays frozen. Doing the same with tasks lets breaks in between each iteration, so this breakage is microtask-specific.

"Queue" but a set — the first runnable task is taken

The spec defines task queue as "not a queue but a set". The processing model picks "the first runnable task in the queue chosen", not mechanically taking the head. Which task queue to choose is implementation-defined. Tasks queued to the same queue are taken in order, but which queue goes first is not guaranteed — don't depend on the order between tasks of different kinds (HTML Standard — 8.1.7.1 Definitions).

await continuation is a microtask​

await looks like "stops here", but actually exits the function midway and registers the continuation as a microtask.

async function main() {
console.log('A before await');
await null; // Whatever the value, continuation goes to microtask
console.log('C after await');
}

main();
console.log('B sync after main call');
A before await
B sync after main call
C after await

await null has nothing to wait for, yet B prints first. Because at await, the function returns to the caller once. Misreading this hides "reading a variable right after await, and it hasn't been rewritten yet" — that class of ordering bug.

setTimeout(fn, 0) isn't "immediately"​

setTimeout only queues a task. The queued task runs after the current task ends and microtasks are empty.

const started = Date.now();
setTimeout(() => {
const waited = Date.now() - started;
console.log(`Task with 0 delay ran ${waited >= 500 ? 'after 500+ ms' : 'under 500 ms'}`);
}, 0);

let sum = 0;
for (let i = 0; i < 3_000_000_000; i++) sum += i;
console.log('sync finished');
sync finished
Task with 0 delay ran after 500+ ms

Until the sync loop ends, the queued task can't run. The 0 means "at earliest this long", not "immediately".

The specified value isn't always used as-is. Browser rules raise the minimum as setTimeout nesting deepens:

If nestingLevel is greater than 5, and timeout is less than 4, then set timeout to 4.

— HTML Standard — 8.7 Timers

Depth (nestingLevel) increases by 1 each time setTimeout is called from inside a setTimeout callback. Per the spec, from depth > 5, 0 waits 4 ms.

This 4 ms isn't Node.js's rule. Node.js only specifies "if delay < 1, set to 1" (Node.js — Timers).

Confirming with 40 nested levels. If the browser rule applies, deep levels each take 4 ms or more, so the total exceeds 100 ms. If it doesn't apply, Node.js's 1 ms floor decides and the total lands around 50 ms. Drawing a line at 100 ms lets you judge with one boolean whether the rule is not applying (false means it isn't; true can also just mean a slow environment, so it settles nothing).

// Nest setTimeout(fn, 0) 40 levels
let n = 0;
const t0 = Date.now();
function step() {
n++;
if (n < 40) { setTimeout(step, 0); return; }
console.log(`40 levels total exceeded 100 ms? ${Date.now() - t0 >= 100}`);
}
setTimeout(step, 0);
40 levels total exceeded 100 ms? false

false, so the browser rule doesn't apply here. Because it's run on Node.js. The depth at which the floor starts differs by implementation, so don't reuse a total measured in one browser as an estimate.

Because minimum rules differ per environment, avoid interval-dependent writings.

Painting only fits between tasks​

The browser's opportunity to update the screen is in spec called rendering opportunity, decided by hardware refresh rate and more.

This specification does not mandate any particular model for selecting rendering opportunities. But for example, if the browser is attempting to achieve a 60Hz refresh rate, then rendering opportunities occur at a maximum of every 60th of a second (about 16.7ms).

— HTML Standard — 8.1.7.3 Processing model

The important thing: even when the opportunity arrives, nothing happens until the current task ends. A task running for 500 ms lets every opportunity in that time pass through unused. Click handlers also stay queued until that task ends.

This is what "all three are about the browser's main thread" from Core Web Vitals contains. Poor INP means a long task sits between interaction and reaction — a layer separate from server slowness.

Three directions for offloading heavy processing​

Three directions for shortening long tasks.

DirectionDoFits
SplitDivide the process; let one task pass betweenMany iterations. Stays on the same thread
Send to another threadHand off to Web Worker / worker_threadsHeavy computation where splitting doesn't reduce total
Push to serverHave precomputed results returnedProcessing that shouldn't depend on device performance

Splitting is just "queue a task at each boundary and await it".

// Split heavy processing, letting a task pass between
const yieldToEventLoop = () => new Promise<void>((resolve) => setTimeout(resolve, 0));

let interrupted = 0;
const timer = setInterval(() => interrupted++, 1);

async function sumAll(count: number): Promise<number> {
let sum = 0;
for (let i = 0; i < count; i++) {
sum += i;
if (i % 1_000_000 === 0) await yieldToEventLoop(); // Other tasks can run here
}
return sum;
}

const total = await sumAll(20_000_000);
clearInterval(timer);
console.log('computation done', total > 0);
console.log('timer ran during computation', interrupted > 0);
computation done true
timer ran during computation true

Yield with setTimeout, not Promise.resolve(). Yielding with a microtask stays inside the checkpoint, so other tasks don't run and painting doesn't happen. Total time grows over not splitting. In browsers, an await doesn't break the setTimeout nesting, so the 4 ms floor seen earlier applies here too. The more you yield, the more of that wait piles up. Not a technique to speed up — a technique to let other work interrupt.

If splitting doesn't reduce total, send to another thread. Beyond that is covered by Processes and threads.

Node.js's loop is phase-divided​

Server-side also runs on one thread, but the internals differ. Node.js's event loop cycles through phases, each processing its own queue (Node.js — The Node.js Event Loop).

PhaseProcesses
timerssetTimeout / setInterval callbacks
pending callbacksDeferred I/O callbacks from the previous iteration
idle, prepareInternal use
pollFetching new I/O events and most of their callbacks
checksetImmediate callbacks
close callbacksCleanup like socket.on('close', ...)

process.nextTick isn't part of the phases. The docs state "the nextTickQueue will be processed after the current operation is completed, regardless of the current phase of the event loop". Like microtasks, positioned as interrupting between phases, so queuing heavy work here prevents phases from advancing.

The basic "two tiers of tasks and microtasks" is shared; the task side has finer breakdown — that maps to the browser.

Common misconceptions​

"Adding async makes it run on another thread." — It doesn't. async function bodies run on the same thread. Concurrency comes only from the part where the waiting partner is outside JavaScript. Putting heavy computation in an async function still blocks the main thread.

"setTimeout(fn, 0) runs immediately." — Just queues a task. Doesn't run until the current task and microtasks clear. In browsers, the spec raises the minimum to 4 ms from nesting depth > 5.

"Inserting Promise.resolve().then() yields to other processing." — Doesn't. Microtasks flow within the same checkpoint, so tasks don't run and painting doesn't happen. To yield, queue a task.

"await-ing heavy processing prevents freezing." — await waits for "things whose completion is signaled from outside". await-ing a synchronous heavy loop still runs that loop on the main thread.

"Event loops prevent deadlocks." — They don't. Mutual waits can be built between Promises. Separately, continuously queuing microtasks inside microtasks prevents the checkpoint from ending, blocking the next task. Absence of mutex and progress guarantee are separate concerns.

Exercises​

Q1. A "sort" button on a listing page: pressing it now takes 2 seconds to respond. The Network tab shows no communication. What do you suspect and fix?

Answer and explanation

No communication, so the cause is synchronous processing on the main thread.

The sort computation itself is likely one long task. 2 seconds from press to response means the handler's task runs the whole time, and painting opportunities and other events pass through.

Triage:

  1. Look for synchronous processing inside the click handler. Loops proportional to item count are the first candidate
  2. Reduce items and see if time drops proportionally. If proportional, it's a complexity problem — see if Complexity and Array scanning patterns can reduce it
  3. If can't reduce, offload. Three options: split and yield, send to another thread, or receive precomputed results from the server

Adding async or inserting await doesn't shorten synchronous loops. To split, actually queue tasks within the loop.

Q2. What is the output of this code, in order?

console.log('a');
setTimeout(() => console.log('b'), 0);
Promise.resolve().then(() => console.log('c'));
queueMicrotask(() => console.log('d'));
console.log('e');
Answer and explanation

Answer: a e c d b

  1. a and e are synchronous, so they print in the current task
  2. When the current task ends, microtask checkpoint begins. then and queueMicrotask are both microtasks, so c d in queued order
  3. Only after microtasks empty does b run as the next task

setTimeout's 0 doesn't move it up. There's priority between tasks and microtasks; the number is after that.

Q3. An aggregation page summing a large number of rows; scrolling stops during it. "Wrapped in Promise to make it async, so it should be fixed" — but it wasn't. Why?

Answer and explanation

Because Promise-wrapping still runs synchronous processing on the same thread all at once.

Writing new Promise((resolve) => { /* loop over the rows */ resolve(); }) still executes the executor content synchronously on the spot. What Promise makes async is how the result is received, not the content.

Same reason, making it an async function and adding await doesn't change. What await yields is "exit the function and put the continuation on a microtask" — and microtasks flow within the same checkpoint. Painting doesn't happen; scroll doesn't move.

Two fixes:

  • Divide the loop and queue tasks with setTimeout between — scroll works but total time grows
  • Send to another thread — pass the array to a Web Worker and receive just the sum; main thread stays free

Decide by whether you want the screen operable during processing. For operable, split. For "just wait for result", another thread is simpler.

Summary​

  • On one event loop, one thread runs JavaScript, and wait time itself is outside JavaScript. That's why concurrency appears. Starting another thread is covered by Processes and threads
  • Event loop runs one task, then drains microtasks until empty, repeatedly
  • await continuations and Promise then are microtasks. They flow within the same checkpoint, not counted as yielding
  • setTimeout(fn, 0) just queues a task. Doesn't run until the current task ends. In browsers, the spec sets the minimum to 4 ms from nesting depth > 5 (not Node.js's rule)
  • Painting opportunities come regularly but nothing happens until the current task ends. Long tasks block interaction and painting
  • Offloading directions: split, another thread, server — three. Splitting doesn't shorten total but lets other work interrupt
  • Node.js's loop cycles phases. Task side is subdivided, but the two-tier structure is shared
Related references