The event loop
This lecture assumes you have used promises, async/await and setTimeout, and have seen an event loop diagram at least once. That ground is covered by Andres Käver's base course lecture on promises and the event loop — read it before this page if any of it is hazy.
What follows is not how to use them. It is the exact order in which things run, and what that order costs you.
Why nothing happened
1.1 The message nobody sees
button.addEventListener("click", () => {
status.textContent = "Loading…";
const result = crunchNumbers(); // takes two seconds
status.textContent = `Done: ${result}`;
});
See the demo. Click the button. What is on the screen during those two seconds?
What you see
The page freezes, then shows Done. "Loading…" never appears. Nothing else on the page reacts either — no hover effects, no scrolling, no other clicks — until it is over.
The DOM did change. Read status.textContent on the line after and it says "Loading…". It just never reached the screen.
1.2 One thing at a time
JavaScript in a page runs on one thread, and a piece of JavaScript runs to completion before anything else happens. No other handler, no timer, and — the part you just saw — no painting.
That guarantee is worth a lot. It is why this is safe:
if (cache.has(key)) {
return cache.get(key); // nothing can have changed in between
}
Nothing runs between those two lines. No lock, no race, no second thread touching cache.
The price is the other half of the same sentence. If your code takes two seconds, nothing else happens for two seconds. The browser cannot paint "Loading…" because painting is also something else.
Two weeks ago you met the sharpest version of this: a synchronous loop cannot be cancelled, because the click that would cancel it cannot run until the loop is over. This lecture is the vocabulary for why.
The loop
2.1 Four steps
The browser runs one procedure, forever:
- Take one task from a queue and run it to completion.
- Run every microtask, including any queued while doing so, until there are none left.
- If it is time for a new frame, render.
- Go back to 1.
The click handler in 1.1 was one task. Step 1 ran it for two seconds. Step 3, the only place painting happens, came afterwards — by which point "Loading…" had already been overwritten.
Everything else on this page is a consequence of those four lines.
Step 2 also runs at a few points inside a task: after each event listener callback and each requestAnimationFrame callback returns, provided no other JavaScript is still on the stack. So when a real click reaches two listeners, the first listener's promise callbacks run before the second listener starts. Trigger the same click from code with button.click() and they do not, because your calling script is still on the stack. The Archibald talk in the reading list walks through exactly this case.
2.2 Two queues
| Microtasks | Tasks |
|---|---|
.then / .catch / .finally callbacks | setTimeout, setInterval |
the rest of an async function after await | event handlers — click, input, load |
queueMicrotask(fn) | postMessage, MessageChannel |
MutationObserver callbacks | network callbacks |
A promise callback and a timer are not the same kind of thing. One goes into the queue that step 2 empties completely; the other waits its turn in step 1.
console.log("1");
setTimeout(() => console.log("2"), 0);
Promise.resolve().then(() => console.log("3"));
queueMicrotask(() => console.log("4"));
console.log("5");
Output
1
5
3
4
2
The whole script is one task. 1 and 5 print while it runs. 3 and 4 were queued as microtasks during it, so step 2 runs them as soon as it ends. 2 is a task, so it waits for the next pass of the loop.
Traced line by line, with each queued callback named by what it will print:
| # | What happens | Microtask queue | Task queue | Printed |
|---|---|---|---|---|
| 1 | console.log("1") | — | — | 1 |
| 2 | setTimeout queues a task | — | log 2 | |
| 3 | .then queues a microtask | log 3 | log 2 | |
| 4 | queueMicrotask queues one | log 3, log 4 | log 2 | |
| 5 | console.log("5") | log 3, log 4 | log 2 | 5 |
| 6 | script ends — step 2 drains | log 4 | log 2 | 3 |
| 7 | still draining | — | log 2 | 4 |
| 8 | microtasks empty — step 1 | — | — | 2 |
Or step through it:
console.log("1");
setTimeout(() => console.log("2"), 0);
Promise.resolve().then(() => console.log("3"));
queueMicrotask(() => console.log("4"));
console.log("5");
- script
- empty
- empty
nothing printed yetThe whole script is one task. Step 1 of the loop starts running it.
2.3 Draining means everything
Promise.resolve().then(() => {
console.log("a");
Promise.resolve().then(() => console.log("c"));
});
Promise.resolve().then(() => console.log("b"));
Output
a
b
c
c is queued while the queue is being drained, so it joins the back of the same queue, after b — and still runs before any task. Step 2 does not stop until the queue is empty, however it got refilled.
Step through it:
Promise.resolve().then(() => {
console.log("a");
Promise.resolve().then(() => console.log("c"));
});
Promise.resolve().then(() => console.log("b"));
- script
- empty
- empty
nothing printed yetThe script task starts.
2.4 After every task, not after all of them
The one to think hardest about:
setTimeout(() => {
console.log("t1");
Promise.resolve().then(() => console.log("m1"));
});
setTimeout(() => {
console.log("t2");
Promise.resolve().then(() => console.log("m2"));
});
Output
t1
m1
t2
m2
If you predicted t1 t2 m1 m2, you had the rule as "tasks, then microtasks". It is "a task, then microtasks". Step 2 runs after every single task.
Step through it:
setTimeout(() => {
console.log("t1");
Promise.resolve().then(() => console.log("m1"));
});
setTimeout(() => {
console.log("t2");
Promise.resolve().then(() => console.log("m2"));
});
- script
- empty
- empty
nothing printed yetThe script task starts.
2.5 A queue that never empties
function forever() {
Promise.resolve().then(forever);
}
forever();
This freezes the tab permanently. The stack unwinds every time, so it is not an infinite loop in the usual sense — but every microtask queues another, step 2 never finishes, and steps 3 and 4 never come. No painting, no clicks, nothing.
Replace the promise with setTimeout(forever) and the page stays responsive, because each iteration is a separate task and the loop gets to render in between.
await
3.1 Everything before the first await is synchronous
async function load() {
console.log("load: start");
await null;
console.log("load: after await");
}
console.log("script: start");
load();
console.log("script: end");
Output
script: start
load: start
script: end
load: after await
An async function runs synchronously up to its first await. Then it returns a promise to its caller. The rest of the function runs as a microtask once the awaited value has settled — straight away, if it already has.
So await does not mean "pause here". It means return to the caller now, and continue later from the microtask queue. script: end prints before load: after await because the caller carried on, and the rest of load had to wait for step 2.
Step through it:
async function load() {
console.log("load: start");
await null;
console.log("load: after await");
}
console.log("script: start");
load();
console.log("script: end");
- script
- empty
- empty
nothing printed yetThe script task starts. Declaring load runs none of its body.
3.2 An await on an async call
async function outer() {
console.log("outer 1");
await inner();
console.log("outer 2");
}
async function inner() {
console.log("inner");
}
outer();
Promise.resolve().then(() => console.log("then"));
Output
outer 1
inner
outer 2
then
inner() runs completely — there is no await inside it — and returns an already-settled promise. await then queues the rest of outer as a microtask. Only after that does the next line register its .then. So the queue is [rest of outer, then], in that order.
Registration order decides it. Swap the last two lines and the answer changes.
Step through it:
async function outer() {
console.log("outer 1");
await inner();
console.log("outer 2");
}
async function inner() {
console.log("inner");
}
outer();
Promise.resolve().then(() => console.log("then"));
- script
- empty
- empty
nothing printed yetThe script task starts.
3.3 Which await can be interrupted
Two weeks ago, analyseRows could be cancelled because of this line:
if (i % 1000 === 0) {
signal?.throwIfAborted();
await new Promise((r) => setTimeout(r));
}
Why a setTimeout promise? await null is shorter and looks as though it would do the same thing — give something else a turn.
It does not. Here is the loop run three ways, with a cancel scheduled as a task one millisecond in:
// yield-test.mjs
async function run(label, yieldFn) {
const controller = new AbortController();
setTimeout(() => controller.abort(), 1); // the "click"
let i = 0;
try {
for (; i < 2_000_000; i++) {
if (i % 1000 === 0) {
controller.signal.throwIfAborted();
await yieldFn();
}
}
console.log(`${label} → ran to the end, the cancel never landed`);
} catch {
console.log(`${label} → cancelled at i = ${i}`);
}
}
await run("await null", () => null);
await run("await queueMicrotask", () => new Promise((r) => queueMicrotask(r)));
await run("await setTimeout", () => new Promise((r) => setTimeout(r)));
Output (Node)
await null → ran to the end, the cancel never landed
await queueMicrotask → ran to the end, the cancel never landed
await setTimeout → cancelled at i = 1000
In a browser the last line may say 2000 or 3000 instead. The first few zero-delay timers are not clamped there (5.2), so a yield can come round before the 1 ms cancel has fired. What does not change is that the first two never stop.
await null puts the rest of the loop in the microtask queue. Step 2 drains that queue completely before moving on, so the loop runs every one of its two million iterations inside a single step 2. The cancel is a task. It waits for step 1, which does not come until the loop is over.
The same is true of a real click on a cancel button — it is a task. And of rendering, which is step 3.
With setTimeout, each yield ends the task, so the abort() task can slip in between two slices of the loop.
The middle case is the one to stop on. That promise is still pending when the loop awaits it — and the cancel still never lands.
await does not yield to the page. A task does.await hands control back to the caller, and that is all it does. The rest of your function is queued as a microtask when the awaited promise settles. Whether the page gets a turn before then depends on what settles that promise.
| The awaited promise is settled… | For example | Clicks, timers, painting |
|---|---|---|
| already, or by another microtask | null, Promise.resolve(), an async call with no await inside, new Promise(r => queueMicrotask(r)) | blocked — the rest runs in the same step 2 |
| by a later task | fetch(url), a setTimeout promise, MessageChannel, scheduler.yield() | run first — step 2 empties and the loop moves on |
Pending is not enough. Something outside the microtask queue has to settle the promise.
That is why await fetch(url) feels as though it yields and await null does not. Neither await yields. The network response arrives as a task; null never needed one.
So to keep a page responsive, or to let a cancel arrive, await something a task will settle: setTimeout, MessageChannel, or scheduler.yield() where it exists.
analyseRows only worked because it happened to use setTimeout. The obvious simplification would have broken it silently.
Rendering
4.1 Where painting happens
Step 3, and nowhere else. Which means painting happens between tasks, never during one — and never between microtasks either, since step 2 runs to completion first.
That is 1.1 fully explained. Both writes to status.textContent happened inside one task. The only state that ever reached step 3 was the last one.
4.2 Fixing the loading message
Yield once, so the message paints:
button.addEventListener("click", async () => {
status.textContent = "Loading…";
await new Promise((r) => requestAnimationFrame(() => setTimeout(r)));
const result = crunchNumbers();
status.textContent = `Done: ${result}`;
});
requestAnimationFrame runs in step 3, just before painting. A setTimeout from inside it becomes a task that runs after that frame is painted. So the rest of the handler is guaranteed to start after "Loading…" is on screen.
A bare setTimeout usually works too, but is not guaranteed to straddle a paint. And as 3.3 showed, await null would not work at all.
One edge: requestAnimationFrame does not run in a hidden tab (4.3). If the user switches away straight after clicking, the handler waits until they come back. For a loading message, that is harmless.
"Loading…" now appears. Then the page freezes for two seconds anyway, because crunchNumbers is still one long task. The message is visible; the button, the scrollbar and every other click are dead.
The real fix is to stop holding the thread: break the work into pieces with task yields between them, or move it off the thread entirely with a Worker. Section 7.3 shows how to size the pieces.
4.3 requestAnimationFrame
rAF callbacks run in step 3, immediately before the browser calculates styles and layout and paints. That makes it the right place for anything visual:
- it runs at most once per frame, so there is no wasted work between frames;
- it does not run in a hidden tab, which is what you want for animation;
- it receives a high-resolution timestamp, so animation can be driven by elapsed time rather than by how many frames happened to run.
At 60 frames per second the entire frame — your JavaScript, style, layout and paint — has 16.7 ms. In practice your share is well under 10.
Timers
5.1 The delay is a minimum
const start = Date.now();
setTimeout(() => console.log("waited", Date.now() - start), 100);
while (Date.now() - start < 500) {}
Output
waited 500
setTimeout(fn, 100) means put fn in the task queue no sooner than 100 ms from now. The timer expired on time. The task then waited in the queue, because step 1 was busy with the loop.
A timer's delay is a minimum, not a promise.
5.2 Zero is not zero
const t0 = performance.now();
let n = 0;
function tick() {
if (++n < 100) setTimeout(tick, 0);
else console.log(performance.now() - t0);
}
tick();
Output
roughly 110 ms in Node, 400+ ms in a browser
A hundred "zero" delays add up to a tenth of a second or more.
Node enforces a floor of 1 ms. Browsers are stricter: the HTML specification clamps any timer nested more than five levels deep to at least 4 ms. The clamp is old — early scripts used zero-delay timer loops as busy-waiting and drained batteries — and it is very unlikely to go away.
This matters in section 7. Every setTimeout-based yield costs you at least that floor.
5.3 setInterval drifts
setInterval(tick, 1000) asks for a task every second, and it is only a request. If tick takes 300 ms, or the thread is busy when a run comes due, that run starts late. Missed runs are not saved up and replayed in a burst — they are simply lost, so a counter incremented by the interval falls steadily behind the clock. Hidden tabs make it much worse: browsers throttle their timers to about once a second, or less often.
For anything where timing matters, read the clock rather than counting ticks:
const start = performance.now();
function frame(now) {
const elapsed = now - start; // true elapsed time, whatever happened in between
draw(elapsed);
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
The delay is stored as a 32-bit signed integer, and the largest value that can hold is 2,147,483,647. In milliseconds, that is just under 25 days.
Anything longer overflows. Browsers then fire the timer immediately, with no warning. Node prints a TimeoutOverflowWarning and fires after 1 ms.
The realistic version of this bug is a session timeout. setTimeout(logout, 30 * 24 * 60 * 60 * 1000) — thirty days — logs the user out straight away.
Node's loop
Everything above is the browser's loop, defined in the HTML specification. Node runs a different one, and the differences matter the moment you write a server.
libuv is the C library underneath Node that runs its event loop: it waits on the operating system for network and timer events, and hands work the operating system cannot do asynchronously — file access, DNS lookups, some crypto — to a small pool of background threads.
6.1 Phases
Node's loop runs through fixed phases, in order, round and round:
Microtasks are drained after every individual callback, as in the browser.
6.2 process.nextTick
Node has one more queue, which it drains ahead of promise callbacks: process.nextTick(fn). You will meet it in library source code and in older Node tutorials. You will rarely have a reason to call it yourself. When you need a microtask, queueMicrotask works the same in the browser and in Node, and Node's own documentation recommends it over nextTick for most code.
Testing the order in a scratch file and getting the "wrong" answer
Promise.resolve().then(() => console.log("promise"));
process.nextTick(() => console.log("nextTick"));
console.log("sync");
Saved as main.cjs, this prints sync nextTick promise — the rule as usually taught. Saved as main.mjs, it prints sync promise nextTick. An ES module's top-level code is evaluated inside the promise machinery, so its microtasks drain before Node gets back to the nextTick queue. Inside any callback — a timer, a request handler — the usual order returns.
It is one more reason not to write code whose correctness depends on the order of two different queues.
6.3 setTimeout or setImmediate
setTimeout(() => console.log("timeout"), 0);
setImmediate(() => console.log("immediate"));
At the top level of a script, Node documents this order as not guaranteed — it depends on how long the process took to start relative to the timer's resolution. Run it a few hundred times and you will see both orders.
Inside an I/O callback, the order is guaranteed:
import { readFile } from "node:fs";
readFile(import.meta.filename, () => {
// import.meta.filename: Node 20.11+
setTimeout(() => console.log("timeout"), 0);
setImmediate(() => console.log("immediate"));
});
Output
immediate
timeout
The I/O callback ran in the poll phase. Check, where setImmediate runs, comes directly after poll. Timers do not come round again until the next pass.
setImmediate is also the cheapest task-yield in Node: it has no 1 ms floor. Section 7.2 measures the difference.
What blocking costs
Two lectures in a row have ended on a cost — closures, then objects. This one is a cost you pay in time, and it has the same property as the others: you usually choose it without noticing a choice was offered.
7.1 One thread for everything
On the client, a long task means the page ignores input. The browser measures that directly: Interaction to Next Paint is one of the Core Web Vitals. For every click, tap and key press it measures the time until the next frame is painted, and reports roughly the worst of them for the page. A long task is the usual culprit — an input that arrives mid-task waits for it to finish, and so does the paint.
You can watch for long tasks from code:
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
console.warn(`long task: ${entry.duration.toFixed(0)} ms`);
}
}).observe({ type: "longtask", buffered: true });
Anything over 50 ms is reported. The Long Tasks API exists only in Chromium-based browsers; so does its newer relative, type: "long-animation-frame", which also tells you which script was responsible.
On a server it is worse, because one thread serves every request. That is week 9.
The fix in both places is the same idea — do not hold the thread — and the question is how often to let go.
7.2 Measured
Two million iterations of cheap arithmetic, yielding to the task queue with setTimeout at different intervals. The script times the yields separately from the work:
total waiting in yields yields
no yielding: 26 ms 0 ms 0
yield every 100,000: 48 ms 22 ms 19
yield every 10,000: 249 ms 222 ms 199
yield every 1,000: 2,250 ms 2,220 ms 1,999
yield every ~10 ms: 29 ms 2 ms 2
Yielding every thousand iterations — the interval analyseRows used — turns 26 ms of work into over two seconds. More than eighty times slower, and the waiting column shows where it went: the 1 ms floor from 5.2, paid two thousand times. The work itself still took under 30 ms.
The floor is the timer's, not yielding's. The same 1,999 yields with setImmediate instead cost about 15 ms in total (node chunk-cost.mjs 1000 immediate). In a browser, MessageChannel and scheduler.yield() play that role.
One more cost, found by accident. The first version of the script ran the work loop directly inside the async code, with the await in the loop body. The time-based mode then took 54 ms, of which only a few milliseconds were yields — the loop itself ran twice as slowly as the plain version. A function containing await is re-entered after every pause, and the engine did not optimise the hot loop inside it as well. Moving the loop into an ordinary synchronous function brought the time-based mode down to 29 ms. Keep the heavy loop in plain code; let the async function only decide when to pause.
Reproduce it yourself
The script is here. Run each mode in its own process:
node chunk-cost.mjs none
node chunk-cost.mjs 1000
node chunk-cost.mjs 10000
node chunk-cost.mjs 100000
node chunk-cost.mjs time
node chunk-cost.mjs 1000 immediate
The first four are stable to within a few milliseconds across runs. The time-based one varies, because the number of yields depends on how fast your machine is — which is the point of it.
What makes the numbers trustworthy:
- The work is accumulated and printed. Every mode prints the same
acc. If the loop's result were never used, the engine would be entitled to skip the work, and you would be timing nothing. - The same
accin every mode is also the check that each variant did identical work, so the only difference between rows is the yielding. - A separate process per mode, so no variant runs on a heap or a JIT state warmed by the one before.
- Waiting and working are timed separately. If the working time grows between modes, yielding is not the only cost — which is how the slow-loop problem above was found.
- The clock is checked every thousand iterations, not every iteration. An early version of the time-based mode called
performance.now()two million times and reported 194 ms — most of it the clock calls themselves. Checking it periodically cost nothing measurable.
In a browser the fixed-interval rows get worse, not better, because of the 4 ms nested-timer clamp.
7.3 Yield by time, not by count
A count is a guess about how long the work takes, made once, on your machine. On a slower device it yields too rarely and the page stutters; on a faster one it yields far too often and pays the floor for nothing.
Yield when a time budget runs out instead:
const yieldToPage = globalThis.scheduler?.yield ? () => globalThis.scheduler.yield() : () => new Promise((r) => setTimeout(r));
function processRange(items, start, end) {
for (let i = start; i < end; i++) handleItem(items[i]); // plain, synchronous
}
async function processAll(items, { signal } = {}) {
let i = 0;
while (i < items.length) {
const deadline = performance.now() + 10;
while (i < items.length && performance.now() < deadline) {
const end = Math.min(i + 1000, items.length);
processRange(items, i, end);
i = end;
}
if (i < items.length) {
await yieldToPage();
signal?.throwIfAborted();
}
}
}
About 10 ms of work, then a task yield. The page gets a turn roughly every frame, a cancel can land within about 10 ms, and the total cost stays close to the raw work — 29 ms against 26, rather than 2,250. The clock is read once per thousand items, not on every one, and the heavy loop is a plain function, as 7.2 recommends. The cancel is checked straight after the yield, because that is the only moment one can have arrived.
scheduler.yield() (Chromium 129+, Firefox 142+, not yet Safari) is a purpose-built task yield: it lets the browser handle input and paint, but puts your continuation ahead of other queued tasks, so your work does not lose its place. Feature-detect it, as above; the setTimeout version is the fallback.
React's concurrent rendering is this section applied to rendering. Its scheduler works in slices of a few milliseconds and schedules the next slice with MessageChannel rather than setTimeout — precisely to avoid the timer clamp from 5.2. Updates you mark with startTransition or useDeferredValue are rendered this way, and a click can interrupt them.
What it slices is React's own work of rendering components. A crunchNumbers() call inside a component body is still one uninterruptible call, transition or not.
Chunking keeps the page responsive, but the work still happens on the main thread and still competes with everything else. If it is genuinely heavy — parsing a large file, image processing, anything measured in seconds — move it to a Web Worker in the browser or worker_threads in Node. That is real parallelism, at the cost of passing data by message rather than sharing it.
7.4 Cancelling from React
Every React developer has written this effect, or needed it:
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [error, setError] = useState(null);
useEffect(() => {
const controller = new AbortController();
setUser(null);
setError(null);
fetch(`/api/users/${userId}`, { signal: controller.signal })
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then(setUser)
.catch((err) => {
if (err.name !== "AbortError") setError(err);
});
return () => controller.abort();
}, [userId]);
// …
}
Without the cleanup there is a race. Click user 1, then user 2. Each response arrives as its own task, in whatever order the network delivers them. If user 1's is slower, it lands last and the page shows user 1 under user 2's heading.
With the cleanup, changing userId makes React run the previous effect's cleanup, which aborts the old request. Its promise rejects with an AbortError, the .catch ignores it, and setUser for the stale user never runs. Aborting also stops the download itself, which a simple let ignore = true flag in the cleanup does not.
Run-to-completion (1.2) closes the remaining gap. Once the response body has been read, the rest of the chain up to setUser runs in one microtask drain, and nothing else can run in the middle of it. The cleanup runs while React handles some other event — the click that changed userId — so it lands either before that drain, and the stale result is discarded, or after it, when the stale user has been set and is immediately cleared by the new effect's setUser(null).
In development, <StrictMode> mounts every component, cleans it up and mounts it again. You will see one cancelled request in the Network panel for each such effect. That is the cleanup working, not a bug.
The same pattern cancels CPU work, with one condition that is the whole of section 3.3:
useEffect(() => {
const controller = new AbortController();
summariseRows(rows, { signal: controller.signal }) // written like processAll in 7.3
.then(setSummary)
.catch((err) => {
if (err.name !== "AbortError") setError(err);
});
return () => controller.abort();
}, [rows]);
The cleanup runs in a task. It can only reach summariseRows if summariseRows lets tasks run — yields with setTimeout, MessageChannel or scheduler.yield(). Written with await null, it would finish all its work before React ever got the chance to call abort(), and the cancel would be decoration.
See it in DevTools
Everything on this page is visible in the Chrome Performance panel. It takes ten minutes, and it is the same skill you will use on a real INP problem.
Download the demo page and open it in Chrome. It has three buttons, all doing the same second of busy-waiting in crunchNumbers: the handler from 1.1, the 4.2 fix, and the 7.3 time slices. A requestAnimationFrame loop on the page moves a bar and counts frames, so you can see the thread stop without opening anything.
- Open DevTools → Performance. Tick Screenshots.
- Press Record, click 1 · Bug once, wait two seconds, press Stop.
- In the Main track, find the one-second Task bar. It carries a red corner: Chrome marks every task over 50 ms as a long task. Expand it —
Event: click→Function call→crunchNumbers. That is step 1 of the loop, running for a second. - Look at the screenshots strip above it. The page goes from "Idle" straight to "Done". There is no frame with "Loading…" in it, because no frame was produced during the task. That is section 1.1, on a timeline.
- Find the click in the Interactions track. Hover it: the interaction is split into input delay, processing time and presentation delay. Processing is the second you just saw. This is the measurement behind INP (7.1).
- Look inside any short task for small Run microtasks blocks at its end. That is step 2.
Then record the other two buttons:
- 2 · rAF fix, the handler from 4.2. You should now see the click task, then Animation frame fired, a frame and its paint, then a Timer fired task containing
crunchNumbers. The screenshots show "Loading…" — and then the bar freezes anyway, because the work is still one long task. - 3 · Time-sliced, the loop from 7.3. Instead of one long bar, you should see a row of ~10 ms tasks with frames between them and no red corners. The page keeps scrolling and responding while it works, and you can type in the input.
Each run reports how many frames the browser managed to paint during it. That number — none, one, then roughly sixty — is the whole lecture in one line.
Event names are from recent Chrome and shift a little between versions. The Firefox Profiler shows the same structure under different names.
Take these 5 things from here
- One task, then every microtask, then maybe a frame. Painting only happens between tasks.
awaitreturns to the caller immediately. Everything after it runs later, as a microtask.awaitonly lets the page in if a task settles the promise. Already settled, or settled by a microtask, lets nothing in.- A timer's delay is a minimum. It fires when the thread is free, not when the time is up.
- Yielding has a price. Yield by elapsed time, not by item count, and keep the heavy loop in plain synchronous code.
You should now be able to
- Predict the output order of code mixing synchronous logging, timers, promise callbacks and
queueMicrotask, and justify each position from the four steps of the loop. - Explain why a DOM change made during a long task never reaches the screen — in plain DOM code and in a React event handler — and restructure the code so that it does.
- Say which part of an
asyncfunction runs synchronously and which is deferred, and trace anawaitthrough a call to anotherasyncfunction. - Explain why
await null— or awaiting a pending promise that a microtask will settle — cannot make a loop cancellable or keep a page responsive, whileawait fetch(url)can, by asking what settles the promise. - Explain why a timer fired later than requested by reference to the loop rather than the timer.
- Describe Node's phases well enough to say where
setTimeout,setImmediateand promise callbacks run, and choosesetImmediateoversetTimeoutas a yield in Node. - Choose how often to yield by measuring its cost, rather than by guessing a chunk size.
- Cancel a fetch or long-running work from a React effect with
AbortController, and explain why cancelling CPU work only works if that work yields to tasks. - Record an interaction in the Chrome Performance panel and point to the long task, the microtask runs and the missing frame.
Reading
Required
- Andres Käver's base course lecture on promises and the event loop
- Jake Archibald, "In the Loop" — the best 35 minutes on this topic that exists; sections 2 and 4 of this page are a written version of it
- MDN: Using microtasks in JavaScript with
queueMicrotask()
Recommended
- HTML Standard: Event loops — the processing model is the four steps of 2.1, written out in full
- Node.js: The event loop, timers, and
process.nextTick() - Philip Roberts, "What the heck is the event loop anyway?" — older, and pre-dates microtasks being widely understood, but the visualisation is still the clearest