One thread
JavaScript in a browser runs on a single thread — the same thread that does layout, paint and event handling. While your code is running, nothing else can: no rendering, no clicks, no scroll.
Everything asynchronous is a way of saying "run this later, on that same thread, when it is free".
The parts
- The call stack — what is running now. Functions push and pop.
- The macrotask queue — timers, events, network callbacks, messages.
- The microtask queue — promise callbacks,
queueMicrotask,MutationObserver. - The event loop — when the stack empties, drain all microtasks, then take one macrotask, then let the browser render.
Microtasks run to exhaustion before the next macrotask. A promise callback always beats a setTimeout(…, 0) queued at the same moment.
The ordering puzzle
1, 5, 3, 4, 2. The two synchronous logs run first. Then the stack empties and both microtasks run, in the order they were queued. The timer is a macrotask, so it goes last — even with a delay of 0.
console.log("1");
setTimeout(() => console.log("2"), 0);
Promise.resolve().then(() => console.log("3"));
queueMicrotask(() => console.log("4"));
console.log("5");
setTimeout is a minimum, not a promise
Nested timers are also clamped to about 4ms after the fifth level, and background tabs throttle them to once a second or less. A timer is a request, not a guarantee.
setTimeout(fn, 0); // "as soon as the stack is empty and it is your turn"
// a timer cannot interrupt this — the log happens three seconds late
const end = Date.now() + 3000;
while (Date.now() < end) { }
Blocking
- No frame is painted, so a spinner set just before it never appears.
- Clicks are queued, not lost — they all fire at once afterwards.
- Chrome shows "page unresponsive" after long enough.
// this freezes the page for as long as it takes
button.addEventListener("click", () => {
const result = veryLongLoop(data); // 2 seconds
render(result);
});
// 1. break it into chunks and yield between them
async function process(items) {
for (let i = 0; i < items.length; i++) {
work(items[i]);
if (i % 200 === 0) await new Promise((r) => setTimeout(r));
}
}
// 2. let the browser tell you when it is free
await scheduler.yield?.(); // where supported
// 3. move it off the thread entirely
const worker = new Worker("/work.js");
worker.postMessage(data);
worker.onmessage = (e) => render(e.data);
Rendering fits between macrotasks
A style change does not paint immediately; it paints when the loop next gets a turn. If you never yield, the reader sees the before and after states with nothing in between.
spinner.hidden = false;
const result = veryLongLoop(); // no paint has happened yet
spinner.hidden = true; // the spinner was never seen
// give the browser a frame first
spinner.hidden = false;
await new Promise(requestAnimationFrame);
const result = veryLongLoop();
Microtask starvation
Because microtasks drain completely before anything else happens, a microtask that queues another microtask never lets the loop continue. A setTimeout version of the same loop would be fine.
function spin() {
Promise.resolve().then(spin); // queues a new microtask forever
}
spin(); // the page is now permanently frozen
async does not mean parallel
await does not make anything faster; it lets the thread do something else while a network request or a timer is outstanding. Two seconds of computation is two seconds either way — that is what a Worker is for.
// these run in sequence: 2 seconds
const a = await slow();
const b = await slow();
// these overlap: 1 second — but only because the WAITING overlaps
const [a, b] = await Promise.all([slow(), slow()]);