JavaScript Event Loop Demystified: What Senior Devs Know That Juniors Don't

Technical PM + Software Engineer
If you've ever been surprised that a Promise callback runs before a setTimeout callback, or hunted a race condition that only appears in production, the root cause is almost always a misunderstanding of the event loop. This article gives a compact, practical mental model of the JavaScript event loop that senior developers use daily: the call stack, the web APIs, the macrotask (callback) queue, the microtask queue, and the event loop tick that orders execution. I'll show real code examples, explain why Promises run before setTimeout, demonstrate setTimeout(0) tricks and their pitfalls, and provide debugging and mitigation patterns you can apply immediately.
The mental model: call stack, web APIs, and two queues
At runtime, JavaScript engines maintain a call stack where synchronous functions run to completion. When you call an async API like setTimeout or fetch, the engine delegates the timer or network IO to the host environment (browser or Node) — the web APIs. Those web APIs execute independently and, upon completion, schedule callbacks back into JavaScript via queues. Critically, there are two kinds of queues: the macrotask (callback) queue and the microtask queue. Microtasks include Promise .then, .catch, .finally callbacks and process.nextTick in Node, while macrotasks include setTimeout, setInterval, DOM events, and I/O callbacks.
The event loop repeatedly performs a simple loop: if the call stack is empty, it drains the microtask queue fully, running each microtask in FIFO order, then takes the next macrotask from the macrotask queue and runs it. After that macrotask completes, the loop again drains microtasks, and so on. The ordering rules (microtasks drain between macrotasks) are what produce many unintuitive behaviors.
- Call stack: runs synchronous code top to bottom.
- Web APIs: timers, network, DOM events — outside the JS engine.
- Microtask queue: Promise handlers, process.nextTick.
- Macrotask queue: setTimeout, setInterval, DOM events, I/O.
- Event loop tick: empty stack -> drain microtasks -> run one macrotask -> repeat.
Synchronous execution and the call stack
Synchronous code executes on the call stack until completion. No async callback can interrupt a running synchronous function. For example, if you run a long computational loop, timers and Promise callbacks wait until the function returns and the stack is empty. This behavior avoids races during single-threaded execution but causes UI jank when computations block the thread.
Example: calling heavyComputation before any timers will delay their callbacks. Code: heavyComputation(); setTimeout(fn, 0); The setTimeout callback can't run until heavyComputation returns, because callbacks re-enter only when the stack is empty.
- Long synchronous blocks block callbacks and rendering.
- Always prefer splitting heavy work or using Web Workers for CPU-bound tasks.
- Synchronous functions cannot be preempted by microtasks or macrotasks.
Where web APIs and callbacks come into play
When you call setTimeout, the browser starts a timer in its Web API layer. When the timer elapses, the callback is queued as a macrotask. Similarly, XHR/fetch resolves in the Web API layer and enqueues a callback. The important point is: completion triggers enqueuing, not immediate execution. That enqueuing respects queue type: Promise resolution handlers (microtasks) are queued into microtasks, timers into macrotasks.
Practical implication: network or timer latency doesn't guarantee ordering relative to microtasks. A promise resolved synchronously or a microtask scheduled during a macrotask will run before the next macrotask even if the macrotask's timer elapsed earlier.
- Async API completion -> callback enqueued (not executed immediately).
- Microtask enqueued during a macrotask runs before the next macrotask.
- Timers are macrotasks; they get processed after draining microtasks.
Microtasks vs macrotasks: why Promise callbacks beat setTimeout
This is the classic question: why does Promise.resolve().then(fn) execute before setTimeout(fn, 0)? The answer is queue priority. The event loop drains the microtask queue fully between macrotasks. When the currently executing code enqueues a microtask and a macrotask, the microtask runs immediately after the current stack empties, before any macrotask is taken. Therefore, Promise callbacks consistently run before setTimeout callbacks scheduled in the same turn.
Example: Code: console.log('start'); setTimeout(() => console.log('timeout'), 0); Promise.resolve().then(() => console.log('promise')); console.log('end'); Output order: start, end, promise, timeout. Why: synchronous logs run first, then microtasks (promise) execute, then the macrotask scheduled by setTimeout runs on the next tick.
- Promises -> microtask queue -> higher priority within a tick.
- setTimeout -> macrotask queue -> runs after microtasks and the current macrotask.
- Use microtasks for fast, intra-tick continuations; use macrotasks when you want to yield to the host for repaints or external events.
setTimeout(0) trick and its pitfalls
Developers often use setTimeout(fn, 0) to defer work until after the current execution. While it defers, it's a macrotask and will run after all microtasks are drained. That means setTimeout(0) is useful when you explicitly want to yield to the browser for rendering or to allow microtasks to settle. However, it's a coarse tool. It doesn't guarantee timing, and on some platforms minimum timer granularity or throttling (e.g., inactive tabs) changes semantics.
A common anti-pattern is using setTimeout(0) to avoid race conditions instead of tightening the control flow. For deterministic ordering, use Promise chaining, async/await, or explicit synchronization (e.g., Promise.all, locks). If you only want to guarantee deferral but still run before other macrotasks scheduled by external events, consider using queueMicrotask if you mean a microtask.
- Use setTimeout(0) to yield a macrotask boundary (e.g., after DOM changes to allow a repaint).
- Do not use setTimeout to race-control async flows; prefer Promise-based coordination.
- On some environments, timer clamping or throttling changes behavior.
Real race-condition examples and deterministic fixes
Imagine two async sources updating shared state: a message from WebSocket and an HTTP fetch. Each callback mutates a shared object when it completes. Because completion order is nondeterministic, you can observe inconsistent states. The fix is to avoid implicit shared-state mutation and instead compose results with Promise-based coordination or a dedicated state machine.
Example: Instead of let a,b; ws.onmessage = msg => a = msg; fetch(url).then(res => b = res); UI update reads a and b and misbehaves. Deterministic solution: Promise.all([waitForSocketMessage(), fetch(url)]).then(([a,b]) => update(a,b)); or use sequence guards that check for expected states before mutating. For cancellations, attach an abort signal or track a token to ignore stale callbacks.
- For parallel async ops that must combine results, use Promise.all or explicit coordination.
- To ignore stale responses, increment a request token and check it in callbacks.
- Prefer async/await for linear, readable control flow that reduces accidental shared mutation.
Practical debugging and mental-checklist
When faced with surprising ordering, run through a checklist: 1) Is the surprising callback a microtask or macrotask? 2) Is there a long-running synchronous function blocking the stack? 3) Are timers being clamped or throttled by the environment? 4) Are you mutating shared state without ordering guarantees? Tools: the browser DevTools' async stack traces, breakpoints, and Performance panel are your friends. Insert console.log with labels and timestamps to observe ordering. Use small reproductions to isolate the rules.
Tips: Use console.time/console.timeEnd to measure where time is spent. Replace setTimeout(0) with queueMicrotask or Promise.resolve().then(...) when you need a microtask. When you need to defer to the next render frame, use requestAnimationFrame rather than setTimeout. For Node, remember process.nextTick has even higher priority than Promise microtasks in older versions; consult your runtime's specifics.
- Checklist: identify microtask vs macrotask, inspect long sync blocks, check throttling, isolate state mutations.
- Replace setTimeout(0) with queueMicrotask or Promise.resolve().then when you need microtask-level deferral.
- Use requestAnimationFrame for rendering deferral and Web Workers for heavy computation.
Conclusion
Mastering the event loop is about internalizing simple, deterministic rules: synchronous code runs on the call stack; web APIs enqueue callbacks; microtasks are drained fully between macrotasks; and the event loop proceeds in ticks that honor that ordering. With that mental model you can predict why Promises run before setTimeout, when setTimeout(0) helps or hurts, and how to avoid race conditions by using Promise coordination, tokens, or explicit state machines. These are the patterns senior developers rely on to make asynchronous JavaScript reliable and maintainable.
Action Checklist
- Experiment: create small snippets that mix Promise.resolve().then, setTimeout(..., 0), and synchronous work to observe ordering.
- Refactor: replace ad-hoc setTimeout deferrals with Promise chaining, queueMicrotask, or requestAnimationFrame depending on intent.
- Instrument: use DevTools Performance and async stack traces to watch real app behavior and find blocking synchronous hotspots.