JavaScript in the browser runs on a single thread: it can only do one thing at a time. Yet pages handle clicks, timers, network responses and animations all at once without freezing. How? The answer is the event loop, the scheduling system that decides what runs next.
Understanding the event loop explains puzzles that confuse many developers. Why does setTimeout(fn, 0) not run immediately? Why do promise callbacks run before a zero-delay timeout? Why does a long loop freeze the whole page, buttons and all? And why does await let other code run in the middle of your function?
In this lesson you'll learn the parts of the system, the call stack, the task queue, the microtask queue and the rendering step, then work through output-order puzzles until you can predict them confidently. These ideas tie together what you learned about timers, promises and async/await, and they're a favourite topic in technical interviews.
The parts of the event loop
| Part | What it holds |
|---|---|
| Call stack | The functions running right now. JavaScript runs whatever is on top until the stack is empty. |
| Web APIs | The browser's own helpers that wait for things: timers, network requests, event listeners. They run outside your JavaScript. |
| Task queue (macrotasks) | Callbacks ready to run: timer callbacks, event handlers, message events. |
| Microtask queue | Higher-priority callbacks: promise .then/catch/finally, code after await, queueMicrotask, MutationObserver. |
| Rendering | Style, layout and paint, plus requestAnimationFrame callbacks. |
When you call setTimeout(fn, 1000), the browser's timer waits the second, then puts fn in the task queue. It still has to wait its turn.
The loop, step by step
The event loop repeats this cycle over and over:
- Take the oldest task from the task queue and run it until the call stack is empty. (The first task is your script itself.)
- Run every microtask in the microtask queue, including any new microtasks added while doing so.
- If it's time for a new frame, run
requestAnimationFramecallbacks and render the page. - Go back to step 1.
The key consequences: nothing interrupts code that's already running, all microtasks run before the next task, and the page can only repaint between tasks.
Why promises beat setTimeout
console.log("1");
setTimeout(() => console.log("4"), 0);
Promise.resolve().then(() => console.log("3"));
console.log("2");
// 1, 2, 3, 41 and 2 are part of the current task. The promise callback is a microtask, so it runs as soon as the script finishes. The timeout is a task, so it waits for the next turn of the loop. A zero-delay timeout means "as soon as possible after everything else queued", not "now".
Long tasks freeze the page
Because rendering and event handling only happen between tasks, a function that runs for two seconds blocks everything for two seconds: clicks don't respond, animations stop and the page can't repaint. Browsers report tasks longer than 50ms as long tasks, and Google's Interaction to Next Paint (INP) measurement penalises them.
Fixes include:
- Splitting work into chunks and yielding between them with
await scheduler.yield()(where supported) orawait new Promise(r => setTimeout(r)). - Moving heavy computation to a Web Worker, which runs on another thread.
- Doing less work: caching results, debouncing events, or processing only what's visible.
JavaScript Event Loop Syntax
Scheduling tools
setTimeout(fn, 0); // task: after current task and all microtasks
queueMicrotask(fn); // microtask: right after the current task
Promise.resolve().then(fn); // microtask
requestAnimationFrame(fn); // before the next paint
await null; // rest of the async function becomes a microtask
await new Promise(r => setTimeout(r)); // yield to tasks and renderingJavaScript Event Loop Summary
- JavaScript runs one thing at a time on a single thread; the event loop schedules what runs next.
- The browser's Web APIs wait for timers, network and events, then queue callbacks.
- After each task, every microtask (promise callbacks, code after await) runs before the next task.
- That's why
Promise.thenruns beforesetTimeout(fn, 0). - Long tasks block clicks and rendering; split them up or move them to a Web Worker.
JavaScript Event Loop Examples
Each example runs in the page. Edit the code and press Run to see what changes.
Predicting the order
Each message is numbered in the order it really appears. Try to explain why before you run it.
console.log("1: script start");
setTimeout(() => console.log("8: timeout"), 0);
Promise.resolve()
.then(() => console.log("4: promise 1"))
.then(() => console.log("7: promise 2"));
queueMicrotask(() => console.log("5: queueMicrotask"));
(async () => {
console.log("2: async, before await");
await null;
console.log("6: after await");
})();
console.log("3: script end");Line-by-line explanation
- Line 1Synchronous code runs first, in order.
- Line 3The timeout callback is a task; it waits for the next turn of the loop.
- Lines 5 to 7The first
thenis queued as a microtask now; the second only after the first runs. - Line 9Another microtask, queued straight after the first
then. - Lines 11 to 15Code before
awaitruns synchronously; the rest becomes a microtask. - Line 17The last synchronous line. Then microtasks run in the order queued, and finally the timeout.
A frozen page and the fix
Press "Freeze" and try clicking "Count" straight away. Then try "Chunked".
<button id="count">Count: 0</button>
<button id="freeze">Freeze for 1s</button>
<button id="chunked">Chunked work</button>
<p id="status"></p>let n = 0;
const status = document.querySelector("#status");
document.querySelector("#count").addEventListener("click", (e) => {
e.target.textContent = `Count: ${++n}`;
});
document.querySelector("#freeze").addEventListener("click", () => {
status.textContent = "Working...";
const end = Date.now() + 1000;
while (Date.now() < end) {}
status.textContent = "Done (the page was frozen, and 'Working...' never appeared)";
});
document.querySelector("#chunked").addEventListener("click", async () => {
for (let i = 1; i <= 20; i++) {
const end = Date.now() + 100;
while (Date.now() < end) {}
status.textContent = `Chunk ${i} of 20`;
await new Promise((r) => setTimeout(r));
}
status.textContent = "Done, and the page stayed responsive";
});Line-by-line explanation
- Line 8This text change can't be painted, because rendering waits for the task to finish.
- Lines 9 to 10A second of busy work in one task blocks clicks and painting.
- Lines 15 to 18The same total work in 100ms chunks.
- Line 19Yielding with a timeout lets clicks and repaints happen between chunks.
Microtasks can starve the page
let ticks = 0;
function loopMicro() {
if (++ticks < 5) queueMicrotask(loopMicro);
console.log("microtask", ticks);
}
setTimeout(() => console.log("timeout finally runs after", ticks, "microtasks"), 0);
queueMicrotask(loopMicro);
console.log("sync code done");Line-by-line explanation
- Line 3Each microtask schedules another one.
- Line 7The timeout is a task, so it must wait until the microtask queue is completely empty.
- Line 8If this chain never stopped, the timeout, clicks and rendering would never get a turn.
Common JavaScript Event Loop Mistakes
Expecting setTimeout(fn, 0) to run immediately
let ready = false;
setTimeout(() => (ready = true), 0);
console.log(ready); // false: the callback hasn't run yetsetTimeout(() => {
const ready = true;
console.log(ready);
}, 0);Long synchronous loops in handlers
button.onclick = () => {
for (const row of hugeList) expensiveProcess(row); // freezes the page
};button.onclick = async () => {
for (const [i, row] of hugeList.entries()) {
expensiveProcess(row);
if (i % 200 === 0) await new Promise(r => setTimeout(r));
}
};Showing a loading message before heavy sync work
status.textContent = "Loading..."; // never painted
heavySyncWork();status.textContent = "Loading...";
await new Promise(r => requestAnimationFrame(() => setTimeout(r)));
heavySyncWork();JavaScript Event Loop in Real Projects
The event loop in practice
Browser performance tools show long tasks as red-flagged blocks in the Performance panel. React schedules rendering work in small chunks so typing stays responsive. Node.js runs on the same event loop idea for servers, handling thousands of connections on one thread. And output-order questions like "what does this log?" are classic JavaScript interview questions.
JavaScript Event Loop Exercises
Answer each one and check it instantly. If you get stuck, open the hint before the answer.
Schedule a microtask.
(() => console.log("microtask"));
console.log("sync");
queueMicrotask adds a callback to the microtask queue.What is logged?
setTimeout(() => console.log("t"), 0);
Promise.resolve().then(() => console.log("p"));
console.log("s");How many things can JavaScript's main thread do at once?
Which of these is a microtask?
order() should resolve with the array ["sync", "micro", "task"], recording in the order things actually run. The pushes are in the wrong kinds of callbacks. Fix it without changing the final array.
function order() {
const log = [];
return new Promise((resolve) => {
Promise.resolve().then(() => log.push("task"));
setTimeout(() => {
log.push("micro");
resolve(log);
}, 0);
log.push("sync");
});
}
order().then((log) => console.log(log));- Records the real order
JavaScript Event Loop Quiz
5 questions to check what you learned.
When does a setTimeout(fn, 0) callback run?
- Immediately
- After exactly 0 milliseconds, interrupting other code
- After the current code and all queued microtasks have finished
- Never
Why can't the page repaint during a long loop?
- Loops disable the GPU
- Rendering only happens between tasks, and the loop is still running
- The browser is downloading
- CSS is paused
What happens to the rest of an async function after await?
- It runs immediately
- It's discarded
- It moves to a new thread
- It's scheduled to continue once the awaited promise settles
How long is a "long task" according to browsers?
- More than 50ms
- More than 5ms
- More than 1 second
- More than 10 seconds
Why can an endless chain of microtasks freeze the page?
- Microtasks use more memory
- All microtasks must finish before the next task or render, so tasks and painting never get a turn
- Microtasks block the network
- They run on the GPU
JavaScript Event Loop Challenge
Process a big list without freezing
Write an async function processInChunks(items, size, fn) that:
- Calls
fn(item)for every item, in order - After every
sizeitems, yields to the event loop withawait new Promise((r) => setTimeout(r)) - Resolves with the number of items processed
async function processInChunks(items, size, fn) {
}
const nums = Array.from({ length: 10 }, (_, i) => i);
processInChunks(nums, 3, (n) => console.log(n)).then((count) => console.log("done", count));- Processes every item in order
- Yields between chunks
- Returns a promise