JavaScript Event Loop

JavaScript runs on a single thread, which means it can only do one thing at a time — yet it routinely handles timers, network requests, and user clicks without freezing the page. The event loop is the mechanism that makes this possible: it constantly checks whether the call stack is empty, and if it is, it pulls the next waiting piece of work (a timer callback, a resolved promise, a click handler) and runs it. Understanding the event loop is the key to understanding why console.log statements sometimes appear in an order that looks wrong at first glance, and why a setTimeout(fn, 0) never actually runs immediately.

Overview: How the Event Loop Works

A JavaScript engine (like V8, which powers Chrome and Node.js) has one call stack. The call stack is where function calls are tracked: when a function is invoked, a frame is pushed onto the stack; when it returns, the frame is popped off. As long as there is something on the call stack, the engine is busy executing synchronous code and nothing else can run — this is what people mean when they say JavaScript is single-threaded.

Asynchronous work (timers, network requests, file I/O, DOM events) is not handled by the JavaScript engine itself. Instead, it is handed off to the surrounding host environment — the browser’s Web APIs or Node.js’s C++ bindings and libuv. These environments run the work in the background and, when it finishes, place a callback into one of two queues:

  • The macrotask queue (often just called the task queue or callback queue) — holds callbacks from setTimeout, setInterval, DOM events, and I/O completions.
  • The microtask queue — holds callbacks from resolved/rejected Promises (.then, .catch, .finally), queueMicrotask, and (in Node.js) process.nextTick in its own even-higher-priority queue.

The event loop’s job is deceptively simple: repeatedly check if the call stack is empty, and if it is, take the next item from a queue and push it onto the stack to run. The critical rule that trips up almost every learner is this: after every single synchronous task finishes, the engine fully drains the entire microtask queue before it is allowed to run even one more macrotask. Microtasks can even queue more microtasks, and all of those still run before the next macrotask. This is why promise callbacks consistently run before setTimeout callbacks, even when the timeout is scheduled for 0 milliseconds.

Syntax

The event loop itself is not something you call directly — it is a background process the engine runs automatically. What you write is code that gets scheduled into one queue or another. The table below shows the most common ways to schedule work, and which queue each one uses.

API Queue Typical use
setTimeout(fn, ms) Macrotask Run fn after at least ms milliseconds
setInterval(fn, ms) Macrotask Run fn repeatedly every ms milliseconds
Promise.prototype.then/catch/finally Microtask React to a settled promise
queueMicrotask(fn) Microtask Schedule fn to run before the next render/macrotask
process.nextTick(fn) (Node.js only) Next-tick queue (before microtasks) Highest-priority deferred callback in Node
requestAnimationFrame(fn) (browser only) Before the next repaint Sync visual updates to the display refresh

Here is what scheduling into each queue actually looks like in code:

setTimeout(() => {
  // runs later, as a macrotask
}, 0);

queueMicrotask(() => {
  // runs sooner, as a microtask — before the next macrotask
});

Promise.resolve().then(() => {
  // .then callbacks are also queued as microtasks
});

Examples

Example 1: Microtasks always win over macrotasks

console.log('Start');

setTimeout(() => {
  console.log('Timeout callback');
}, 0);

Promise.resolve().then(() => {
  console.log('Promise callback');
});

console.log('End');

Output:

Start
End
Promise callback
Timeout callback

All four lines look like they should run in the order they’re written, but only 'Start' and 'End' are synchronous — they run immediately while the call stack is busy. setTimeout hands its callback to the macrotask queue and Promise.resolve().then hands its callback to the microtask queue. Once the synchronous code finishes and the stack is empty, the event loop drains the microtask queue first (printing 'Promise callback'), and only then picks up the oldest macrotask (printing 'Timeout callback') — regardless of the fact that the timeout was scheduled with a delay of 0.

Example 2: The microtask queue is fully drained between each macrotask

console.log('script start');

setTimeout(() => {
  console.log('setTimeout 1');
  Promise.resolve().then(() => console.log('promise inside timeout'));
}, 0);

Promise.resolve()
  .then(() => {
    console.log('promise 1');
    return Promise.resolve();
  })
  .then(() => {
    console.log('promise 2');
  });

setTimeout(() => {
  console.log('setTimeout 2');
}, 0);

console.log('script end');

Output:

script start
script end
promise 1
promise 2
setTimeout 1
promise inside timeout
setTimeout 2

The synchronous lines run first: 'script start', then 'script end', with both setTimeout calls simply registering their callbacks in the macrotask queue along the way. With the stack empty, the microtask queue drains: 'promise 1' logs, and because its callback returns a new promise, the chained .then needs one extra microtask tick to unwrap it before logging 'promise 2' — but both still finish before any macrotask runs. Only now does the event loop pull the first macrotask off the queue, running 'setTimeout 1'. That callback queues a fresh microtask, and true to the rule, the engine drains it ('promise inside timeout') before touching the second macrotask, 'setTimeout 2'.

Example 3: Synchronous code always blocks the loop, no matter what’s queued

console.log('Task A start');

function blockFor(ms) {
  const end = Date.now() + ms;
  while (Date.now() < end) {
    // busy-wait to simulate a CPU-heavy synchronous task
  }
}

setTimeout(() => {
  console.log('Timer fired');
}, 0);

blockFor(200);

console.log('Task A end');

Output:

Task A start
Task A end
Timer fired

Even though the timer is scheduled for 0 milliseconds, it cannot fire until the call stack is empty — and the call stack stays busy for the full 200ms that blockFor spins in its while loop. This is the core lesson about the event loop’s limits: it never interrupts running synchronous code, so a long-running loop, a heavy computation, or a huge JSON.parse can delay every pending timer, promise, and even user input like scrolling or clicking.

Under the Hood: Step by Step

  1. The engine executes the current script’s synchronous code top to bottom, pushing and popping frames on the call stack as functions are called and return.
  2. Whenever it encounters something asynchronous (setTimeout, a fetch, a promise executor), it registers that work with the host environment (browser Web APIs or Node’s libuv) and immediately continues to the next line — it does not wait.
  3. When a piece of async work completes (a timer elapses, a promise settles), its callback is placed into the appropriate queue rather than run right away.
  4. Once the current synchronous script finishes and the call stack is completely empty, the event loop checks the microtask queue and runs every callback in it, one at a time — including any new microtasks those callbacks themselves add — until the queue is empty.
  5. Only then does the event loop take a single callback from the macrotask queue and run it to completion (pushing it onto the now-empty call stack).
  6. Immediately after that one macrotask, the loop drains the microtask queue again before moving to the next macrotask. In browsers, rendering updates are typically interleaved here too, roughly once per macrotask/microtask cycle.
  7. This cycle — one macrotask, then all available microtasks, repeat — continues for the entire lifetime of the program.

Common Mistakes

Mistake 1: Assuming setTimeout(fn, 0) runs ‘immediately’ or ‘in parallel’. It only means ‘run fn as a macrotask as soon as the queue reaches it’ — it still waits for the current synchronous code and the entire microtask queue to finish first, as shown in Example 1. There is no parallelism happening; everything still executes on the same single thread.

Mistake 2: Writing an unbounded synchronous loop and expecting the UI or timers to stay responsive. Because the event loop cannot interrupt running code, a large synchronous for loop over millions of items, or a recursive function without any awaited pause, will freeze timers, promise resolutions, animations, and user input for its entire duration. The fix is to break the work into chunks and yield control back to the event loop between them, for example with setTimeout(processNextChunk, 0) or, in modern code, by awaiting something between batches so the microtask/macrotask queues get a chance to run.

Mistake 3: Chaining so many .then() calls or recursive promises that microtasks starve macrotasks. Because the microtask queue must be fully drained before any macrotask runs, code that keeps re-queuing new microtasks forever (for example, a promise chain that recursively calls itself with no exit) can delay timers and rendering indefinitely, even though the CPU is technically ‘idle’ between individual microtasks. Watch for accidental infinite microtask recursion, especially in retry logic built on promises.

Best Practices

  • Prefer async/await over manual .then() chains for readability, but remember it is still built entirely on the microtask queue described above — an await pauses only the current function, not the whole program.
  • Break up CPU-heavy synchronous work into smaller chunks (using setTimeout, queueMicrotask, or, in browsers, requestIdleCallback) so the event loop can service timers, I/O, and user interaction between chunks.
  • Use queueMicrotask when you specifically need a callback to run before the next macrotask but after the current synchronous code — for example, batching multiple synchronous state updates into a single notification.
  • Never rely on the exact delay value passed to setTimeout; treat it as a minimum, not a guarantee, since the callback still has to wait for the call stack and microtask queue to clear.
  • In Node.js, remember process.nextTick runs before the promise microtask queue on every tick — useful for library authors, but avoid overusing it in application code since it can starve I/O callbacks the same way runaway microtasks can.
  • When debugging ordering issues, mentally separate your code into ‘runs now (synchronous)’, ‘runs soon (microtask)’, and ‘runs later (macrotask)’ — most confusing output boils down to misjudging which bucket a line of code falls into.

Practice Exercises

Exercise 1: Without running it, predict the exact console output order of this snippet, then explain why each line lands where it does: it logs 'A' synchronously, schedules a setTimeout that logs 'B', schedules a Promise.resolve().then that logs 'C', and finally logs 'D' synchronously.

Exercise 2: Write a function logInOrder() that, when called, logs 'first' synchronously, then guarantees 'second' logs before any timer but after all synchronous code, and 'third' logs after every currently scheduled timer with a 0ms delay. Which APIs do you need for the second and third lines?

Exercise 3: Take the busy-wait example from this lesson (Example 3) and rewrite blockFor so it no longer blocks the event loop, using either chunked setTimeout calls or async/await with a small delay between iterations. Verify that a setTimeout(() => console.log('tick'), 50) scheduled before your rewritten function now fires close to its intended time instead of being delayed.

Summary

  • JavaScript has a single call stack; the event loop lets async work happen without blocking that stack by deferring callbacks into queues.
  • The macrotask queue holds setTimeout, setInterval, and I/O/event callbacks; the microtask queue holds promise callbacks and queueMicrotask callbacks.
  • After every synchronous task, the entire microtask queue is drained before the next single macrotask runs — this is why promises consistently ‘beat’ timers.
  • A setTimeout delay is a minimum wait, never a guarantee, because the callback still queues behind the call stack and the microtask queue.
  • Long synchronous code blocks everything — timers, promises, rendering, and input — because the event loop can never interrupt running JavaScript.
  • Understanding which bucket (synchronous, microtask, macrotask) a line of code falls into is the fastest way to predict asynchronous output order.