"JavaScript is single-threaded" is true of your code on one thread and false of everything around it. The browser runs networking, parsing, compositing and garbage collection on other threads; Node runs file-system work on a thread pool; and both let you start real parallel threads of JavaScript with workers. What is single-threaded is each agent's event loop: one call stack, fed by queues of tasks and microtasks, in an order that is precisely specified — by the HTML standard in browsers and by libuv plus Node's own rules on the server. This post makes that order exact: tasks vs microtasks, where rendering and requestAnimationFrame fit, the ordering puzzles that come out of it, Node's phases with process.nextTick and setImmediate, and then true parallelism with workers, structured clone, transferables, SharedArrayBuffer and Atomics. It ends with the production view: long tasks, INP and yielding with scheduler.yield().

Series map: 00 Engines & Runtime → 01 Event Loop & Concurrency (this post) → 02 Objects, Prototypes & Metaprogramming → 03 Async Engineering → 04 Modules, Packaging & TypeScript at Scale → 05 Performance, Memory & Production Debugging. Future: see the extensions table in 05.


Learning Outcome

  • Describe one turn of the browser event loop: task, microtask checkpoint, and the "update the rendering" steps.
  • Predict the output of any mix of synchronous code, setTimeout, promises, queueMicrotask and async/await, including extra promise ticks.
  • Place requestAnimationFrame, ResizeObserver and IntersectionObserver callbacks correctly in a frame.
  • Name Node's six event-loop phases and order process.nextTick, promise callbacks, setTimeout and setImmediate, in CommonJS and ES modules.
  • Choose between Web Workers, worker_threads, SharedArrayBuffer and plain message passing, and know what structured clone can and cannot copy.
  • Use Atomics correctly and explain why cross-origin isolation is required in browsers.
  • Find and break up long tasks with scheduler.yield() and a fallback, to improve INP.

Chapter 1 — The model

Terminology

  • Agent — a call stack, a set of queues and one event loop. A window (with its same-origin iframes), each worker, and each Node worker_threads worker are separate agents.
  • Task (often "macrotask") — a unit of work queued by the host: a timer firing, an input event, a network response, a postMessage. Browsers keep several task queues (by task source) and may choose which queue to serve next — this is how input can be prioritised over timers.
  • Microtask — a smaller unit queued by promise reactions, queueMicrotask, and MutationObserver. There is one microtask queue per agent.
  • Microtask checkpoint — the step that runs microtasks until the queue is empty, including microtasks queued by microtasks.
  • Rendering opportunity — a moment when the browser can present a new frame, typically aligned with the display refresh (about every 16.7 ms at 60 Hz). Browsers may skip frames or throttle hidden pages.

One turn of the browser loop

  1. Pick the oldest task from a task queue the browser chooses, and run it to completion. The call stack empties.
  2. Perform a microtask checkpoint: run microtasks until none remain.
  3. If this is a rendering opportunity and there is something to render, update the rendering (Chapter 3).
  4. Repeat.

One turn of the event loop: a task, then all microtasks, then optional rendering; and Node's six libuv phases with nextTick and microtasks drained between callbacks

Two rules produce nearly every "surprising" order:

  • Microtasks run whenever the JavaScript stack becomes empty, not only between tasks. The HTML spec's "clean up after running script" step performs a microtask checkpoint whenever the JavaScript execution context stack becomes empty — after a task, but also after each event listener the browser invokes and after each requestAnimationFrame callback.
  • The checkpoint drains the queue completely. A microtask that queues another microtask keeps the loop in step 2. An infinite chain of microtasks freezes the page as surely as while (true) — rendering and input never get a turn.

Watch out: setTimeout(fn, 0) is not "run next". HTML clamps the delay to at least 4 ms once timers are nested more than 5 levels deep, browsers throttle timers in background tabs heavily, and a timer is only a task — everything already queued, and the rendering step, can run first.


Chapter 2 — Ordering puzzles

Work each one out before reading the answer. All outputs below were checked in Node 22 (the same rules apply in browsers for these cases).

Puzzle 1 — the basics

1
2
3
4
5
6
7
console.log('1');
setTimeout(() => console.log('2'), 0);
Promise.resolve().then(() => console.log('3'));
queueMicrotask(() => console.log('4'));
(async () => { console.log('5'); await null; console.log('6'); })();
console.log('7');

Output: 1 5 7 3 4 6 2.

  • 1, 5, 7 are synchronous. The body of an async function runs synchronously up to its first await.
  • Three microtasks are queued in order: the then callback (3), the queueMicrotask callback (4), the continuation after await null (6).
  • The timer is a task, so 2 comes after the microtask checkpoint.

Puzzle 2 — async functions calling async functions

1
2
3
4
5
6
7
8
async function a1() { console.log('a1 start'); await a2(); console.log('a1 end'); }
async function a2() { console.log('a2'); }
console.log('script start');
setTimeout(() => console.log('setTimeout'), 0);
a1();
new Promise(r => { console.log('p1'); r(); }).then(() => console.log('p2'));
console.log('script end');

Output: script start, a1 start, a2, p1, script end, a1 end, p2, setTimeout.

The promise executor (p1) runs synchronously. a2() returns an already-fulfilled native promise; since ES2019, await on a native promise takes a single microtask hop, so a1 end is queued before p2. (Older engines needed three hops and printed p2 first — a well-known behaviour change, which is why old blog posts disagree.)

Puzzle 3 — returning a promise from then

1
2
3
4
5
6
7
8
9
Promise.resolve()
.then(() => { console.log('a'); return Promise.resolve('x'); })
.then(v => console.log('b', v));
Promise.resolve()
.then(() => console.log('c'))
.then(() => console.log('d'))
.then(() => console.log('e'))
.then(() => console.log('f'));

Output: a c d e b x f.

Returning a thenable from a then callback does not resolve the outer promise directly. The spec queues a NewPromiseResolveThenableJob, which calls x.then(resolveOuter), which queues another job when x is already fulfilled. Two extra hops, so b lands after e.

Microtask queue after each step Queue contents
start a, c
run a (returns a promise) c, thenable job
run c thenable job, d
run thenable job (x.then(resolve)) d, resolve outer
run d resolve outer, e
resolve outer (queues b) e, b
run e b, f

Trick: in an async function, return await p inside try is needed for the catch to see a rejection, but plain return p costs the same extra hops as Puzzle 3. The difference in hops almost never matters; the difference in error handling often does. Choose by semantics.

Puzzle 4 — user click vs el.click()

1
2
3
4
5
6
7
8
9
button.addEventListener('click', () => {
Promise.resolve().then(() => console.log('micro 1'));
console.log('listener 1');
});
button.addEventListener('click', () => {
Promise.resolve().then(() => console.log('micro 2'));
console.log('listener 2');
});
  • Real user click: listener 1, micro 1, listener 2, micro 2. The browser calls each listener from the event loop, the stack empties after each, and a microtask checkpoint runs in between.
  • button.click() from script: listener 1, listener 2, micro 1, micro 2. The listeners run inside your script's call stack, which is not empty until click() returns and your script finishes.

This is why tests that dispatch synthetic events can pass while the real UI behaves differently — for example when a listener relies on a microtask (such as a framework's batched update) having run before the next listener.


Chapter 3 — Rendering and requestAnimationFrame

When the loop decides to render, the HTML standard's update the rendering step runs, per document, in this order (abridged):

  1. Run the resize steps (fire resize), then the scroll steps (fire scroll).
  2. Evaluate media queries; update animations and send animation events; fullscreen steps.
  3. Run the animation frame callbacks — every requestAnimationFrame callback registered before this frame began.
  4. Recalculate styles and update layout, then deliver ResizeObserver callbacks; if they changed layout, recalculate and deliver again (to a depth limit, after which the browser reports a "ResizeObserver loop" error).
  5. Run the IntersectionObserver update steps.
  6. Paint — "update the rendering or user interface".

Consequences for engineers:

  • rAF is the place for visual writes. Code in a rAF callback runs just before style and layout, so the frame shows its result. A setTimeout(…, 16) animation drifts against the display's refresh and may run twice or zero times per frame.
  • A rAF callback that calls rAF schedules for the next frame, not the current one. That is what makes rAF loops work.
  • Microtasks run between rAF callbacks (the stack empties after each one), so a promise resolved in one rAF callback settles before the next callback runs.
  • Layout thrashing: reading layout (offsetHeight, getBoundingClientRect()) after writing styles forces the browser to do style and layout synchronously, in the middle of your task. Doing it in a loop — write, read, write, read — can force a layout per iteration. Batch all reads first, then all writes.
1
2
3
4
5
6
7
8
9
10
11
// Thrashes: forced synchronous layout on every iteration
for (const el of items) {
el.style.width = (el.offsetWidth + 10) + 'px';
}

// Reads first, then writes: at most one layout, done by the browser at the right time
const widths = items.map(el => el.offsetWidth);
requestAnimationFrame(() => {
items.forEach((el, i) => { el.style.width = (widths[i] + 10) + 'px'; });
});

Common pitfall: using rAF in a background tab to keep "real-time" work going. Browsers stop or heavily throttle rAF in hidden documents (the HTML spec removes hidden documents from rendering). Anything that must keep running — a WebSocket heartbeat, a timer for session expiry — belongs in a timer or a worker, not rAF.


Chapter 4 — The Node.js event loop

Node's loop is libuv's loop plus two queues Node adds on top.

Phase Runs
timers Expired setTimeout / setInterval callbacks
pending callbacks Some system I/O callbacks deferred from the previous iteration (e.g. certain TCP errors)
idle, prepare Internal only
poll Retrieves new I/O events and runs their callbacks; the loop blocks here when there is nothing else to do
check setImmediate callbacks
close callbacks 'close' events such as socket.on('close', …)

The Node documentation notes one change: starting with libuv 1.45.0 (Node 20), timers are run after the poll phase in each iteration; earlier versions ran them before polling.

On top of the phases:

  • process.nextTick queue — drained after the current operation completes, before the loop moves on, regardless of phase.
  • Microtask queue (promises, queueMicrotask) — drained right after the nextTick queue. Every time Node drains the nextTick queue, it drains the microtask queue immediately after.
  • Since Node 11, both queues are drained between every individual callback (after each timer, each immediate), matching browser behaviour. Older Node drained them only between phases.

setTimeout vs setImmediate

1
2
3
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));

From the main module the order is not guaranteed — it depends on how quickly the loop starts relative to the 1 ms minimum timer. Inside an I/O callback it is deterministic: the loop is in the poll phase, so the check phase (immediate) always comes before the next timers phase.

1
2
3
4
5
6
7
8
const fs = require('node:fs');
fs.readFile(__filename, () => {
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
process.nextTick(() => console.log('nextTick'));
});
// nextTick, immediate, timeout — always

nextTick vs promises: CommonJS vs ES modules

1
2
3
4
Promise.resolve().then(() => console.log('resolve'));
queueMicrotask(() => console.log('microtask'));
process.nextTick(() => console.log('nextTick'));
File type Output Why
.cjs (CommonJS) nextTick, resolve, microtask The main script is an ordinary operation; the nextTick queue drains first, then microtasks
.mjs (ES module) resolve, microtask, nextTick ES module evaluation already runs as part of the microtask queue, so microtasks queued there run before Node gets back to the nextTick queue

This is the example from the Node documentation, and it is the reason code that relied on nextTick ordering changes behaviour after a CommonJS-to-ESM migration.

Trick: Node's own advice is to prefer queueMicrotask() over process.nextTick() for deferring work in userland, and setImmediate() over setTimeout(fn, 0) for "after I/O". Reach for nextTick only when you need its specific guarantee — for example, emitting an event after the caller has had a chance to attach listeners, in a constructor.

Watch out: a recursive process.nextTick (or a recursive microtask) starves I/O. The poll phase never runs, so sockets are not read and timers never fire. A recursive setImmediate does not starve I/O: each round waits for the next check phase.

What runs off the main thread in Node

The event loop is single-threaded, but libuv has a thread pool (default 4 threads, UV_THREADPOOL_SIZE) for work without a good asynchronous OS API: most fs operations, dns.lookup, and CPU-heavy crypto and zlib calls. Network sockets do not use the pool; they use the OS's readiness APIs (epoll, kqueue, IOCP). A burst of fs calls or crypto.pbkdf2 calls can therefore queue behind four busy threads — a classic hidden bottleneck.


Chapter 5 — Parallelism: workers

When work is CPU-bound — parsing a large file, image processing, compression, heavy validation — no scheduling trick helps; it must run on another thread.

Web Worker (browser) worker_threads (Node)
Create new Worker(url, { type: 'module' }) new Worker(file, { workerData })
Communicate postMessage / onmessage, MessageChannel postMessage / 'message', MessageChannel, workerData
DOM access None Not applicable
Shared memory SharedArrayBuffer, only when cross-origin isolated SharedArrayBuffer, always
Memory limits Browser-managed resourceLimits option (e.g. maxOldGenerationSizeMb)

Each worker is a separate agent: its own isolate, its own heap, its own event loop. Starting one costs milliseconds and several megabytes, so create a pool once and reuse it rather than spawning a worker per request.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// main.js (browser, module worker)
const worker = new Worker(new URL('./hash-worker.js', import.meta.url), { type: 'module' });
const { promise, resolve } = Promise.withResolvers();
worker.onmessage = e => resolve(e.data);
const bytes = await file.arrayBuffer();
worker.postMessage(bytes, [bytes]); // transfer, do not copy
console.log('sha-256:', await promise); // `bytes` is now detached here (byteLength 0)

// hash-worker.js
self.onmessage = async e => {
const digest = await crypto.subtle.digest('SHA-256', e.data);
self.postMessage([...new Uint8Array(digest)].map(b => b.toString(16).padStart(2, '0')).join(''));
};

Structured clone and transferables

postMessage copies its argument with the structured clone algorithm (the same one behind structuredClone()):

Copied Not copied (throws DataCloneError or is lost)
Primitives except symbols; BigInt Functions, symbols → DataCloneError
Plain objects, arrays, Map, Set, Date, RegExp DOM nodes → DataCloneError
ArrayBuffer, typed arrays, DataView, Blob, File Prototypes: a class instance arrives as a plain object
Error objects (Error, RangeError, …) Private #fields, getters/setters (only the current value is copied)
Circular references (preserved) Property descriptors (everything becomes writable, enumerable)

Copying a 100 MB buffer costs time and doubles memory. Transferring moves ownership instead: the sender's ArrayBuffer becomes detached (byteLength 0) and the receiver gets the same memory with no copy. Transferable types include ArrayBuffer, MessagePort, ImageBitmap, OffscreenCanvas, and the stream types ReadableStream, WritableStream and TransformStream.

1
2
3
4
const buf = new ArrayBuffer(8);
const moved = structuredClone(buf, { transfer: [buf] });
console.log(buf.byteLength, moved.byteLength); // 0 8

Trick: OffscreenCanvas lets a worker own a canvas and render without touching the main thread: const off = canvas.transferControlToOffscreen(); worker.postMessage({ off }, [off]);. Charting and map libraries use this to keep the main thread free for input.


Chapter 6 — Shared memory and Atomics

Message passing shares nothing, which is why it is safe. SharedArrayBuffer shares memory: both threads see the same bytes, with all the hazards of real threads.

Why browsers require cross-origin isolation

Shared memory plus a busy-loop counter gives a high-resolution timer, which is enough to mount Spectre-style side-channel attacks that read memory across origins. Browsers therefore expose SharedArrayBuffer only to cross-origin isolated documents, which requires two response headers on the top-level document:

1
2
3
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp (or: credentialless)

Check crossOriginIsolated at run time. require-corp blocks every cross-origin subresource that does not opt in with CORS or Cross-Origin-Resource-Policy — third-party scripts, images and iframes break — so adopting it is a project, not a header change. Without isolation, postMessage of a SharedArrayBuffer throws. Node has no such restriction.

Data races and Atomics

1
2
3
4
// race.mjs — four threads, 100 000 plain increments each
a[0]++; // read, add, write: not atomic
// printed "non-atomic = 231356" in one run, not 400000

Atomics operations are indivisible and sequentially consistent:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// main.mjs (Node)
import { Worker } from 'node:worker_threads';

const sab = new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT * 2);
const shared = new Int32Array(sab); // [0] = counter, [1] = workers finished

const workers = Array.from({ length: 4 }, () =>
new Worker(new URL('./counter-worker.mjs', import.meta.url), { workerData: sab }));

// Block until all four have finished (allowed in Node; not on a browser main thread)
while (Atomics.load(shared, 1) < 4) Atomics.wait(shared, 1, Atomics.load(shared, 1));
console.log('counter =', Atomics.load(shared, 0)); // counter = 400000
await Promise.all(workers.map(w => w.terminate()));
1
2
3
4
5
6
7
8
// counter-worker.mjs
import { workerData } from 'node:worker_threads';

const shared = new Int32Array(workerData);
for (let i = 0; i < 100_000; i++) Atomics.add(shared, 0, 1);
Atomics.add(shared, 1, 1);
Atomics.notify(shared, 1);
Operation Use
Atomics.load / store Read / write with ordering guarantees
Atomics.add, sub, and, or, xor, exchange Read-modify-write, returns the old value
Atomics.compareExchange(ta, i, expected, replacement) The building block for locks and lock-free structures
Atomics.wait(ta, i, value, timeout?) Sleep while ta[i] === value. Throws on a browser main thread; fine in workers and Node
Atomics.waitAsync(…) Non-blocking wait that returns { async, value } (a promise when async is true); usable on the main thread; Baseline since November 2025
Atomics.notify(ta, i, count?) Wake waiters

Watch out: SharedArrayBuffer gives you raw bytes, not objects. You cannot share a JavaScript object graph between threads; you design a binary layout (a ring buffer, a struct of typed-array fields) and serialise into it. That is the right tool for audio, video, physics or WASM threads — and the wrong tool for sharing application state, where message passing is simpler and safer.


Chapter 7 — Long tasks, INP and yielding

Terminology

  • Long task — any task that occupies the main thread for more than 50 ms. Input that arrives during it waits.
  • INP (Interaction to Next Paint) — the Core Web Vital for responsiveness: roughly the worst latency from a user interaction to the next frame, across the page's life. Good is 200 ms or less; poor is above 500 ms.
  • Yielding — ending the current task deliberately so the browser can handle input and render, then continuing in a new task.

Yielding the right way

Splitting work with setTimeout(0) works, but the continuation goes to the back of the task queue, behind every other queued task (and third-party scripts). scheduler.yield() returns a promise whose continuation is queued with priority above other tasks of the same priority, so your work resumes promptly after the browser has handled input and rendering:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function yieldToMain() {
if (globalThis.scheduler?.yield) return scheduler.yield();
return new Promise(resolve => setTimeout(resolve, 0)); // fallback
}

async function processAll(items) {
let deadline = performance.now() + 50;
for (const item of items) {
processItem(item);
if (performance.now() > deadline) { // yield about every 50 ms, not every item
await yieldToMain();
deadline = performance.now() + 50;
}
}
}

scheduler.yield() shipped in Chrome 129 and Firefox 142; at the time of writing it is not Baseline, so always feature-detect. scheduler.postTask(fn, { priority: 'user-blocking' | 'user-visible' | 'background' }) is the companion API for scheduling new work with a priority. Microtasks are not a way to yield: await Promise.resolve() resumes before the browser can render or handle input.

Finding the long tasks

  • DevTools Performance panel: long tasks are flagged with a red corner; the flame chart shows which function owns the time.
  • Long Animation Frames API (LoAF): new PerformanceObserver(cb).observe({ type: 'long-animation-frame', buffered: true }) reports frames that took longer than 50 ms, with script attribution (source URL, function, invoker). It is the field-data tool for explaining a bad INP.
  • Node: perf_hooks.monitorEventLoopDelay() gives a histogram of event-loop delay; a p99 in the hundreds of milliseconds means a synchronous hot spot (JSON parsing a huge body, a sync crypto call, a regex with catastrophic backtracking).

Common pitfall: "we moved it into a promise, so it's async now". Wrapping CPU-bound work in new Promise(...) or an async function changes nothing — the executor and the code before the first await run synchronously, and the rest runs in microtasks on the same thread. Only yielding (new tasks) or a worker (new thread) keeps the page responsive.


Practice Exercises

Exercise 1 — Order this

1
2
3
4
5
6
7
8
setTimeout(() => console.log('A'), 0);
Promise.resolve().then(() => {
console.log('B');
setTimeout(() => console.log('C'), 0);
queueMicrotask(() => console.log('D'));
});
console.log('E');
Solution

E B D A C. E is synchronous. The checkpoint runs B, which queues timer C (after timer A) and microtask D; the checkpoint keeps draining, so D runs before any task. Then the two timers in the order they were queued: A, C.

Exercise 2 — Starve the page

Write a four-line snippet that freezes a page forever using only promises, and explain why rendering never happens. Then change one call so the page stays responsive.

Solution
1
2
3
function spin() { Promise.resolve().then(spin); }
spin();

Each microtask queues another, and the checkpoint only ends when the queue is empty, so the loop never reaches the rendering step or the next task. Replace Promise.resolve().then(spin) with setTimeout(spin, 0) (or scheduler.postTask(spin)): each iteration becomes a separate task, and rendering and input can happen between them.

Exercise 3 — Node, inside an I/O callback

1
2
3
4
5
6
7
8
const fs = require('node:fs');
fs.readFile(__filename, () => {
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
Promise.resolve().then(() => console.log('promise'));
process.nextTick(() => console.log('tick'));
});
Solution

tick promise immediate timeout. After the I/O callback returns, Node drains the nextTick queue, then microtasks. The loop is in the poll phase, so the check phase (immediate) comes before the next timers phase.

Exercise 4 — The module-format bug

A library emits 'ready' from its constructor with process.nextTick. The application attaches the listener one microtask later:

1
2
3
4
5
6
class Lib extends EventEmitter {
constructor() { super(); process.nextTick(() => this.emit('ready')); }
}
const lib = new Lib();
Promise.resolve().then(() => lib.on('ready', () => console.log('ready')));

In the ESM application this logs ready. The same code in the CommonJS test suite never does. Explain, and fix the library.

Solution

In a .mjs file the top-level code already runs inside the microtask queue, so the promise callback (which attaches the listener) runs before Node returns to the nextTick queue, and the event is caught. In a .cjs file the nextTick queue drains first: 'ready' is emitted before anyone listens, and it is lost (both outputs checked in Node 22). The code only ever worked by accident of queue order. Fix the library so late listeners cannot miss the state: expose it (lib.ready as a promise that stays resolved, or an isReady flag the caller checks first), instead of a one-shot event fired on a timing guess.

Exercise 5 — Worker or not?

For each, choose main thread with yielding, a worker, or a worker with SharedArrayBuffer: (a) filtering a 5 000-row table as the user types; (b) parsing a 200 MB CSV upload; (c) a real-time audio effect exchanging samples with an AudioWorklet; (d) rendering 50 000 points to a canvas at 60 fps.

Solution

(a) Main thread; the work is small — debounce input and yield if it grows. (b) Worker: transfer the ArrayBuffer from file.arrayBuffer() in, stream results back in chunks. (c) SharedArrayBuffer + Atomics ring buffer: audio needs lock-free, copy-free exchange with hard deadlines (requires cross-origin isolation). (d) Worker with OffscreenCanvas, so drawing never blocks input.

Exercise 6 — Break up a long task

A click handler runs items.forEach(render) over 20 000 items and takes about 400 ms. Rewrite it so input stays responsive and the first results appear quickly.

Solution
1
2
3
4
5
6
7
8
9
10
11
button.addEventListener('click', async () => {
showSpinner();
await yieldToMain(); // let the spinner paint first
let deadline = performance.now() + 50;
for (const item of items) {
render(item);
if (performance.now() > deadline) { await yieldToMain(); deadline = performance.now() + 50; }
}
hideSpinner();
});

Each chunk stays under about 50 ms, so no long task blocks input for longer than that; the first yield lets the spinner render. For much larger lists, virtualise the list instead of rendering every item.


Cheat-sheet

What Rule
Browser turn Task → drain microtasks → maybe render → next task
Microtask checkpoint Whenever the JS stack empties; drains until empty
Microtask sources then/catch/finally, await, queueMicrotask, MutationObserver
Task sources Timers, input events, network, postMessage, MessageChannel
Render step order resize/scroll → rAF → style & layout → ResizeObserver → IntersectionObserver → paint
setTimeout clamp ≥ 4 ms after 5 nested levels; throttled in background tabs
await native promise 1 microtask hop (since ES2019)
Return a promise from then 2 extra hops
Node phases timers → pending → idle/prepare → poll → check → close
Node queues nextTick queue, then microtasks, between every callback
CJS vs ESM CJS: nextTick first; ESM: promises first
setImmediate vs setTimeout(0) Unordered from main module; immediate first inside I/O callbacks
libuv pool 4 threads by default (UV_THREADPOOL_SIZE): fs, dns.lookup, some crypto/zlib
Transfer, don't copy postMessage(buf, [buf]), structuredClone(v, { transfer })
SAB in browsers COOP: same-origin + COEP: require-corp / credentialless; check crossOriginIsolated
Long task > 50 ms; yield with scheduler.yield() + setTimeout fallback

What's next

Post 02 turns from when code runs to what it runs on: scopes, closures and the TDZ in precise terms, the four this binding rules, the prototype chain and what class really desugars to, private fields and static blocks, property descriptors, symbols and the iteration protocols, Proxy and Reflect with their invariants, and the weak collections that make caches safe. Post 03 then returns to async code — cancellation, backpressure, concurrency limits and error handling — building on this post's queue model.


References