The event loop, timers and concurrency
The call stack, macrotasks versus microtasks, setTimeout and setInterval, requestAnimationFrame, starvation, and chunking long work.
Stack, tasks and microtasks
JavaScript runs one piece of code at a time on a single call stack. When that stack empties, the event loop takes the next item: first it drains the entire microtask queue, then it runs one macrotask, then renders if anything changed.
console.log('1 sync');
setTimeout(() => console.log('5 setTimeout'), 0);
queueMicrotask(() => console.log('3 microtask'));
Promise.resolve().then(() => console.log('4 promise'));
console.log('2 sync');
// 1 sync, 2 sync, 3 microtask, 4 promise, 5 setTimeout
// microtasks added by microtasks all run before the next timer
Promise.resolve().then(() => {
console.log('a');
Promise.resolve().then(() => console.log('b'));
});
setTimeout(() => console.log('timer waits for a and b'), 0);
// the loop keeps running while await is pending
async function tick() {
console.log('start');
await null; // yields to the microtask queue
console.log('end');
}- Microtasks: promise callbacks,
queueMicrotask,MutationObserver. They run to completion before anything else. - Macrotasks: timers, IO callbacks, message events. One is processed per turn, then microtasks drain again.
- Rendering happens between turns, not in the middle of one — a long synchronous block freezes animation, input and timers alike.
- A promise chain is never a thread; user-visible concurrency comes from waiting, not from running code in parallel.
Timers and animation frames
const id = setTimeout(() => run(), 500); // runs once
clearTimeout(id); // cancel before it fires
const tick = setInterval(() => poll(), 1000);
clearInterval(tick);
// delay is a minimum, not a promise: a busy stack or a hidden tab delays it
// nesting past five levels clamps the delay to about 4ms in browsers
setTimeout(check, 0); // "as soon as the current work ends"
// stop a long-running interval instead of relying on the tab being open
let count = 0;
const limited = setInterval(() => {
if (++count > 10) return clearInterval(limited);
poll();
}, 1000);
// animation: synced to the display refresh, paused when hidden
function animate(now) {
const t = (now - start) / 1000;
element.style.transform = 'translateX(' + (t * 100) + 'px)';
if (t < 2) requestAnimationFrame(animate);
}
const start = performance.now();
requestAnimationFrame(animate);
cancelAnimationFrame(handle);
// measure real elapsed time instead of assuming the frame interval
const dt = now - last;| API | Cadence | Best for |
|---|---|---|
setTimeout | Once, after a minimum delay | Deferring work, retries, debounce |
setInterval | Repeats, drifting under load | Polling where exact spacing does not matter |
requestAnimationFrame | Before each repaint | Animation and DOM updates tied to rendering |
queueMicrotask | Immediately after the current stack | Making a callback consistently asynchronous |
requestIdleCallback | When the browser is idle | Low-priority background work |
⚠️
setInterval does not wait for its own callback. If the work takes longer than the interval, runs pile up on top of each other, and a hidden tab silently rewrites the timing. Schedule the next run from inside the callback with setTimeout when the work is variable or asynchronous.Starvation and chunking long work
A synchronous loop blocks everything: input, rendering and every pending callback. Splitting the work into batches and yielding between them keeps the interface responsive, at the cost of a little overhead.
// naive: the tab freezes until the whole array is done
function processAll(items) {
for (const item of items) heavy(item);
}
// batched: yield to the event loop between batches
function processInChunks(items, batch = 200) {
let i = 0;
function step() {
const end = Math.min(i + batch, items.length);
while (i < end) heavy(items[i++]);
if (i < items.length) setTimeout(step, 0); // let the loop breathe
else done();
}
step();
}
// await a yield point inside an async loop
const nextFrame = () => new Promise(r => setTimeout(r, 0));
async function processAsync(items) {
for (let i = 0; i < items.length; i++) {
heavy(items[i]);
if (i % 200 === 0) await nextFrame(); // render and handle input here
}
}
// move genuinely parallel work off the main thread
const worker = new Worker('./parse.worker.js', { type: 'module' });
worker.postMessage(payload);
worker.onmessage = e => console.log(e.data);- Debounce input at 200-300ms and throttle scroll or resize to one frame; never do heavy work in the handler itself.
- Batch DOM reads before writes so the browser does not recompute layout between each one.
- Use a Web Worker for parsing, compression or large numeric work; it cannot touch the DOM, which keeps the interface honest.
- A microtask loop that always schedules another microtask starves timers and rendering completely — yield with a timer or a frame instead.
FAQ
Is setTimeout(fn, 0) really immediate?
No. It queues a macrotask, so it runs after the current stack and after all pending microtasks, and is clamped to a few milliseconds when nested. Use
queueMicrotask if you genuinely want to run before the next timer.Does async code run in parallel?
Not in the sense of simultaneous execution; the event loop still runs one stack at a time. Concurrency here means overlapping waits — several network requests can be in flight while your code continues.
Related
Errors, exceptions and defensive code Iterators, generators, Map and Set
Last refreshed 2026-09-18.