Learn the theory
JS Event Loop Visualizer: how it works
5 min read · Updated
JavaScript runs your code on a single thread, yet it can wait for timers, network responses and user clicks without freezing. The trick is the event loop: a small, tireless scheduler that decides what runs next. Once you understand its one rule — finish the current work, drain the microtasks, then take one task — every confusing console.log ordering puzzle becomes predictable.
In short
- JavaScript executes one thing at a time on the call stack. Nothing can interrupt running code.
- Timers and I/O are handled by the browser (Web APIs), which later queue callbacks.
- After the stack empties, the loop runs all microtasks (promises,
await,queueMicrotask), then one macrotask (timers, events), then repeats. - That is why a resolved promise always runs before
setTimeout(fn, 0). awaitdoes not block the program — it pauses only the surrounding async function.
The moving parts
Five pieces cooperate. The JavaScript engine only owns the first one; the rest are provided by the host (the browser or Node.js).
| Piece | What it does | Who owns it |
|---|---|---|
| Call stack | Tracks the function currently running and who called it. Last in, first out. Only one frame executes at a time. | JS engine (V8, SpiderMonkey…) |
| Web APIs | Timers, fetch, DOM events, geolocation… They run outside the stack, in parallel, and report back when done. | Browser / Node runtime |
| Macrotask queue | Callbacks from setTimeout, setInterval, UI events, MessageChannel. Also called the task or callback queue. | Host |
| Microtask queue | Promise reactions (.then, .catch, .finally), await continuations, queueMicrotask, MutationObserver. | JS engine |
| Event loop | Watches the stack. When it is empty it picks the next job from the queues according to fixed rules. | Host |
The algorithm, step by step
Each turn of the loop follows the same recipe:
- Run the current task to completion. At startup this is your whole script; later it is a single callback.
- Drain the microtask queue. Run every microtask until the queue is empty — including microtasks queued by other microtasks.
- Render if the browser needs to (style, layout, paint). This is why a long microtask chain freezes the page.
- Take the next macrotask from the queue and go back to step 1.
console.log('script start');
setTimeout(() => console.log('setTimeout'), 0);
Promise.resolve().then(() => console.log('promise'));
console.log('script end');
// script start
// script end
// promise <- microtask: runs right after the script
// setTimeout <- macrotask: waits for the next loop turnOpen the lab above and step through this example (“Promise vs setTimeout”) to see each value move between the panels.
Microtasks vs macrotasks
| Microtask | Macrotask | |
|---|---|---|
| Examples | .then() callbacks, await resumption, queueMicrotask() | setTimeout, setInterval, click handlers, postMessage |
| When it runs | Right after the current script or task, before anything else | One per loop turn, after microtasks are drained |
| Priority | Always first | Always after microtasks |
| Risk | An endless microtask chain starves timers and rendering | A long task blocks the UI and everything queued behind it |
What async/await really does
async/await is syntax over promises. When an async function reaches await, it pauses, hands control back to its caller and registers the rest of the function as a promise reaction. When the awaited promise settles, that continuation is queued as a microtask.
async function fetchUser() {
console.log('1: inside, before await');
await null; // pauses here, even for a non-promise
console.log('3: after await'); // runs later, as a microtask
return 'Ada';
}
fetchUser();
console.log('2: caller keeps running');Returning a promise from a .then() callback costs extra microtask ticks, because the engine has to adopt the inner promise. The “Returning a promise costs extra ticks” example in the lab makes those hidden hops visible.
Classic gotchas
- `setTimeout(fn, 0)` is not instant. It means “as soon as the stack is clear and microtasks are done” — and browsers clamp nested timers to at least 4 ms.
- `var` in loops.
for (var i…) setTimeout(() => log(i))prints the final value three times because all callbacks share onei;letcreates a new binding per iteration. - Timers are minimum delays, not guarantees. A busy stack delays the callback.
- Heavy synchronous work blocks everything. A 200 ms loop means no clicks, no paint and no timers for 200 ms. Split work into chunks or move it to a Web Worker.
- Unhandled rejections surface after the microtask queue drains, which is why they appear “late” in the console.
Browser vs Node.js
Node.js uses libuv and a slightly richer loop with named phases (timers, pending callbacks, poll, check, close). Two extra tools exist there:
process.nextTick()runs even before promise microtasks.setImmediate()runs in the “check” phase, right after I/O polling.- The core rule is the same: finish the current operation, drain the nextTick/microtask queues, then move to the next phase.
Frequently asked questions
- Is JavaScript single-threaded?
- Your JavaScript code runs on a single thread, so only one function executes at a time. The browser itself is multi-threaded: timers, network and rendering happen elsewhere and report back through the queues.
- Why does a promise run before setTimeout(0)?
- Promise callbacks are microtasks and timers are macrotasks. After each task the engine drains every microtask before it looks at the macrotask queue, so the promise wins even when the timer delay is zero.
- What is the difference between a microtask and a macrotask?
- Both are queued callbacks, but microtasks (promises,
queueMicrotask) run immediately after the current script or task and are fully drained, whereas macrotasks (timers, events) run one per loop turn. - Does await block the thread?
- No.
awaitsuspends only the async function it appears in. The call stack unwinds, other code runs, and the rest of the function resumes later as a microtask. - Does setTimeout(fn, 0) run immediately?
- No. It schedules the callback as a macrotask, so it runs only after the current code finishes and all pending microtasks are drained — and browsers enforce a small minimum delay for nested timers.
- Can I paste my own code into the visualizer?
- Yes. The editor supports functions, async/await, promises, timers, loops, destructuring and try/catch. Classes and imports are not supported yet. Everything runs locally in your browser.