Understand the JavaScript event loop with a runnable microtask vs macrotask demo. See the exact execution order, why Promise.then runs before setTimeout, and how the task queues interact.
The JavaScript event loop is the scheduler that decides which piece of code runs next. It alternates between running synchronous code to completion, then draining the microtask queue (Promise callbacks, queueMicrotask, MutationObserver), then picking one macrotask (setTimeout, setInterval, I/O, UI events) and repeating. Understanding the order — sync, microtasks to exhaustion, one macrotask, repeat — is the difference between predicting your code's output and being surprised by it. This page walks through the exact order with a runnable demo and explains the rules you need to internalize.
The engine executes the current call stack top to bottom. Nothing from any queue runs while there's sync code left. Every console.log on the top level of your script fires before any promise callback or timeout.
Once the stack is empty, the engine runs every queued microtask in order — Promise.then, Promise.catch, queueMicrotask, MutationObserver. Critically: if a microtask queues another microtask, that new one runs in the same drain. The queue is drained to empty before moving on.
After microtasks are drained, the engine picks one task from the macrotask queue — a setTimeout callback, an I/O completion, a UI event. Just one. Then back to step 2 to drain microtasks again.
Browsers may render the page between ticks of the event loop, but never in the middle of a sync block or microtask drain. This is why a long-running microtask chain (or tight while-loop) can freeze the UI.
A setTimeout(fn, 0) queues a macrotask. A Promise.resolve().then(fn) queues a microtask. Even though both are 'as soon as possible', microtasks are drained to completion before any macrotask runs, so the Promise callback always wins.
You don't 'use' the event loop — you work with it. The practical takeaways: use queueMicrotask or Promise.resolve().then() when you need to defer work to after the current sync block but before the next render; use setTimeout(fn, 0) when you want to yield to the browser for rendering and UI events.
console.log("1 - synchronous start");
setTimeout(() => console.log("2 - setTimeout (macrotask)"), 0);
Promise.resolve().then(() => {
console.log("3 - Promise.then (microtask)");
// Queuing another microtask from inside a microtask:
queueMicrotask(() => console.log("4 - nested microtask (same drain)"));
});
queueMicrotask(() => {
console.log("5 - queueMicrotask (microtask)");
});
console.log("6 - synchronous end");
// Expected order:
// 1, 6 — sync block runs to completion
// 3, 5 — microtasks drain (Promise.then and queueMicrotask)
// 4 — nested microtask from step 3 runs in the same drain
// 2 — setTimeout macrotask runs after ALL microtasks
// Async/await interaction
async function demo() {
console.log("A - before await");
await Promise.resolve();
console.log("B - after await (microtask)");
}
demo();
console.log("C - sync after demo() call");
// A and C run sync; B runs as a microtask after C.
Open this example in the TryJS playground to edit and run the code instantly in your browser — no signup needed.
The event loop is JavaScript's scheduler. It runs synchronous code to completion, then drains the microtask queue (Promise callbacks, queueMicrotask), then processes exactly one macrotask (setTimeout, I/O, UI events), then repeats. This single-threaded cycle is what makes async code work without blocking.
Microtasks are scheduled by Promise.then, queueMicrotask, and MutationObserver. Macrotasks are scheduled by setTimeout, setInterval, I/O callbacks, and UI events. After every synchronous block, the engine drains ALL microtasks before running the NEXT macrotask — so microtasks always 'beat' macrotasks even if both are queued with zero delay.
Because Promise.then queues a microtask and setTimeout queues a macrotask. The event loop drains all microtasks before picking a macrotask, so the Promise callback runs first — even if you queue it after the setTimeout in source order.
queueMicrotask(fn) schedules fn as a microtask, meaning it runs after the current synchronous block but before the next macrotask (or render). It's the lowest-overhead way to defer work inside a tick, and is how you signal 'do this right after I'm done with this call stack'.
Yes. Because the microtask queue is drained to completion before any macrotask or render, an infinite chain of microtasks (each one queuing another) will freeze the browser. Use setTimeout(fn, 0) to yield back to the event loop when you need rendering to happen.
The code in an async function runs synchronously up to the first await. At that point the function pauses and its continuation (the code after the await) is scheduled as a microtask to run once the awaited promise settles. Everything before the first await is still synchronous.