The event loop: tasks, microtasks and rendering
- Explain run-to-completion and one turn of the event loop: one task, then every queued microtask, then maybe a render
- Predict the log order of synchronous code,
setTimeout(…, 0),.then,queueMicrotaskand the code afterawait - Explain why a long task or a self-requeuing microtask chain freezes input and paint, and split the work with a task-boundary yield
- Tell orderings every host guarantees from host-specific ones:
setImmediateandprocess.nextTickin Node,requestAnimationFramein browsers
Read
JavaScript runs one thing at a time, to completion
FlagA page's JavaScript runs on one thread, the main thread. At any moment exactly one piece of code is running on it, and once a function starts, nothing else (no timer, no click handler, no promise callback) can cut in until the call stack is empty again. MDN calls this run-to-completion. (A Web Worker is a separate thread with its own event loop, which talks to the page by messages; the long-tasks reading comes back to it.)
So "asynchronous" never means "runs at the same time as your other code". It means "runs later, on the same thread, as a separate job, after the current code has finished".
console.log("A");
setTimeout(() => console.log("B"), 0);
console.log("C");
// A
// C
// B
setTimeout(fn, 0) does not run fn now. It hands fn to the browser (or Node) with a note:
"once at least 0 ms have passed, queue this as a job". The current code keeps going, prints
C, and finishes. Only then does the event loop pick up the queued job and print B.
The event loop is the part of the runtime that does that picking. Its whole job:
- Take the next job from a queue.
- Run it until the call stack is empty.
- Repeat. If there's nothing queued, wait.
Everything else in this track (timers, promises, debounce, cancellation) is a way of putting jobs on those queues and reasoning about when they come off.
Tracing an ordering puzzle, queue by queue
FlagThe reliable way to predict output is to keep two lists on paper, tasks and microtasks, and walk the code line by line.
console.log("script start");
setTimeout(() => console.log("timeout"), 0);
function fetchLike() {
console.log("fetchLike called");
return Promise.resolve("data");
}
async function load() {
console.log("load: before await");
const data = await fetchLike();
console.log("load: after await", data);
}
load();
Promise.resolve()
.then(() => console.log("then 1"))
.then(() => console.log("then 2"));
console.log("script end");
Walking it:
script startlogs. The timer is handed off; when it expires it queues a task. Tasks:[timeout].load()is called. An async function runs synchronously up to its firstawait, soload: before awaitlogs, thenfetchLike()runs and logsfetchLike called.awaitgets an already-resolved promise. It still pausesloadand queues the rest of the function as a microtask.load()returns a pending promise to the caller. Microtasks:[rest of load].Promise.resolve().then(…)is already resolved, so its callback is queued immediately. Microtasks:[rest of load, then 1]. The second.thenwaits: it's attached to the promise thatthen 1will resolve, which is still pending.script endlogs. The script, our first task, is done, and the stack is empty.- Drain microtasks:
load: after await datalogs, thenthen 1. Finishingthen 1resolves its promise, which queuesthen 2, and the drain keeps going until the queue is empty, sothen 2logs too. - Next task:
timeout.
script start
load: before await
fetchLike called
script end
load: after await data
then 1
then 2
timeout
The three habits that make these easy:
- Synchronous first. Including the body of an async function up to its first
await, and anew Promiseexecutor, which runs immediately. - A
.thencallback is queued when its promise settles, not when you write.then. A chained.thenwaits for the previous callback to finish. - All microtasks before the next task, however many get queued along the way.
Microtasks can starve everything else
Flag"Drain the microtask queue until it's empty" has a sharp edge: if each microtask queues another, the queue never empties, and the event loop never gets to the next task or the next render.
let n = 0;
function spin() {
n += 1;
if (n < 1_000_000) queueMicrotask(spin);
}
setTimeout(() => console.log("timer sees n =", n), 0);
spin();
// timer sees n = 1000000
The 0 ms timer was due almost immediately, but it waits for all million microtasks. Run
that in a page and, for as long as the chain lasts, nothing paints and no input is handled. Make
the chain unbounded and the tab hangs exactly like while (true) {}.
The same thing happens in disguise:
// ❌ Looks asynchronous, and still never yields to rendering or input
async function pump(queue) {
while (queue.length > 0) {
handle(queue.shift());
await null; // a microtask hop, not a yield
}
}
Every await here resumes as a microtask, so the whole loop runs inside one microtask
checkpoint. "It has await in it" is not the same as "it lets the page breathe". Only a task
boundary does that: a timer, scheduler.yield(), or a message from a MessageChannel.
When is a microtask the right tool? When you want something to run after the current
synchronous code but before anything else can observe the state: batching several
synchronous updates into one flush, or making a callback consistently asynchronous. That's
what queueMicrotask is for. A microtask is never the way to give the browser a turn.
Quiz
What does setTimeout(fn, 0) actually do?
Which of these run as microtasks? (Select all that apply.)
FlagWhich statement about await inside an async function is true?
In a browser, this click handler sets status.textContent = "Working…", runs ~2 s of synchronous work, then sets "Done". The user never sees "Working…". Why?
processAll(rows) freezes the tab for 3 s. Which change lets the page paint progress and respond to clicks while it works?
Which of these orderings can you not rely on?
FlagAt the top level of an ES module (.mjs) in Node you write Promise.resolve().then(a) and then process.nextTick(b). What runs first?
Fill in the blank
Name the three steps of one turn of the browser's event loop.
Flag// 1. run one to completion // 2. run every queued , including ones queued while doing this // 3. if a rendering opportunity has come up, the page
Run flush after the current synchronous code finishes but before any pending timer, without creating a promise yourself.
(flush);Make this loop end its task between batches so the browser can paint and handle input.
Flagfor (let i = 0; i < rows.length; i += 500) { rows.slice(i, i + 500).forEach(summarise); new Promise((resolve) => (resolve, 0)); }
In Node, inside an I/O callback such as fs.readFile's, schedule next so it always runs before a 0 ms timer queued from the same callback.
fs.readFile(path, () => { setTimeout(() => console.log("timeout"), 0); (next); });
Predict the output
setTimeout(() => {
console.log("t1");
Promise.resolve().then(() => console.log("m1"));
}, 0);
setTimeout(() => console.log("t2"), 0);
console.log("sync");async function run() {
console.log("1");
await null;
console.log("2");
}
setTimeout(() => console.log("3"), 0);
run();
console.log("4");Promise.resolve()
.then(() => console.log("a1"))
.then(() => console.log("a2"));
Promise.resolve()
.then(() => console.log("b1"))
.then(() => console.log("b2"));const start = Date.now();
let hops = 0;
setTimeout(() => console.log("timer after", Date.now() - start, "ms"), 0);
function loop() {
hops += 1;
if (Date.now() - start < 100) return Promise.resolve().then(loop);
}
loop();