Get started with 33% off your first certification using code: 33OFFNEW

How the JavaScript event loop works

6 min read
Published on 10th July 2026

You have seen setTimeout(fn, 0) run after code written below it, and you have seen a Promise callback run before a timer that was queued earlier. It looks arbitrary until you know the rules, and then it becomes completely predictable. This article explains the model that decides what runs and when, so you can look at any snippet and know the output before you run it.

JavaScript runs on one thread

A JavaScript engine executes your code on a single thread with a single call stack. One thing happens at a time. There is no second thread quietly running your other functions while the first one waits.

That sounds limiting, and it would be, except that the slow work you care about (network requests, timers, reading files, waiting for a click) does not happen on that thread. It is handed to the host, the browser or Node, which does the waiting elsewhere and hands you back a callback to run when the result is ready. The event loop is the mechanism that decides when those callbacks get their turn on the one thread you have.

The call stack runs to completion

When you call a function, a frame goes on the call stack. When it returns, the frame comes off. While a function is on the stack, nothing else runs. This is the single most important rule: a task runs to completion before anything else gets a turn.

function greet() {
  console.log('hello')
}

function main() {
  greet()
  console.log('done')
}

main()
// hello
// done

Nothing surprising yet. main goes on the stack, calls greet, which logs and returns, then main logs and returns. Synchronous code is just the stack filling and draining.

The consequence is blunt: if one function takes two seconds of pure computation, the whole thread is stuck for two seconds. No clicks handled, no timers fired, no rendering. We will come back to that.

Where async work goes

Now add something asynchronous.

console.log('start')
setTimeout(() => console.log('timeout'), 0)
console.log('end')
// start
// end
// timeout

setTimeout does not pause your code. It registers the callback with the host and returns immediately. The host runs the timer. When the timer is up, the host does not barge in and run your callback. It cannot, because the stack might be busy. Instead it places the callback on a queue and waits.

Only when the call stack is empty does the event loop take the next callback from the queue and push it onto the stack. That is why timeout logs last: start and end run synchronously, the stack empties, and only then does the queued callback get its turn.

Two queues, not one

Here is the part that trips people up. There is not a single queue. There are two, and they do not have equal priority.

The task queue (sometimes called the macrotask queue) holds callbacks from setTimeout, setInterval, I/O, and UI events like clicks.

The microtask queue holds the reactions from resolved promises, anything you pass to queueMicrotask, and MutationObserver callbacks. Because async/await is built on promises, the code after an await is a microtask too.

The rule that ties them together: after each single task finishes, the loop drains the entire microtask queue before it renders or picks up the next task. Microtasks jump ahead of tasks every time.

console.log('A')
setTimeout(() => console.log('B'), 0)
Promise.resolve().then(() => console.log('C'))
console.log('D')
// A
// D
// C
// B

A and D are synchronous. When the stack empties, the loop drains microtasks first, so C runs. Only after the microtask queue is empty does the loop take a task, so B runs last. The timer was queued before the promise resolved, and it still loses. If promises are new to you, the introduction to JavaScript Promises covers how .then produces these reactions.

Microtasks can queue more microtasks

Draining the microtask queue means draining it completely, including any microtasks added while draining. This is where nesting matters.

console.log('1: sync start')

setTimeout(() => console.log('2: timeout'), 0)

Promise.resolve().then(() => {
  console.log('3: promise')
  Promise.resolve().then(() => console.log('4: nested promise'))
})

console.log('5: sync end')

// 1: sync start
// 5: sync end
// 3: promise
// 4: nested promise
// 2: timeout

Trace it by the rules. The synchronous lines run first, giving 1 and 5. The stack empties and the loop drains microtasks: 3 runs and, while running, queues another microtask. That new microtask is drained in the same pass, so 4 runs before the loop moves on. The microtask queue is now empty, so the loop finally takes the one task and logs 2.

A microtask that keeps scheduling more microtasks will starve the task queue and block rendering indefinitely. That is a real way to hang a page, so avoid unbounded recursive microtasks.

setTimeout(fn, 0) is not really zero

A zero delay does not mean immediate. It means "queue this as a task, to run when the stack is clear and the microtasks are drained". On top of that, browsers clamp nested timers to a minimum of about 4 milliseconds once you nest them a few levels deep, per the HTML specification.

So setTimeout(fn, 0) is not a speed trick. It is a way to yield: to let the current task finish, let pending microtasks and rendering happen, and pick your work back up on the next tick.

Do not block the loop

Because everything shares one thread, a long synchronous loop freezes the whole page. Nothing paints and no input is handled until it finishes.

// blocks everything until it is done
for (let i = 0; i < 2_000_000_000; i++) {
  total += heavyWork(i)
}

If the work can be split, hand control back to the loop between chunks so the browser can paint and respond.

function processInChunks(items, handle, chunkSize = 500) {
  let i = 0

  function run() {
    const end = Math.min(i + chunkSize, items.length)
    for (; i < end; i++) handle(items[i])

    if (i < items.length) {
      // yield to the event loop, then continue next tick
      setTimeout(run, 0)
    }
  }

  run()
}

For genuinely CPU-bound work that cannot be sliced, move it off the main thread entirely with a Web Worker, which runs on its own thread and talks to your page by messages. Modern browsers also offer requestIdleCallback and the scheduler.postTask API for prioritising background work, but the principle is the same: never hold the thread for long.

Node.js: the same idea with phases

Node uses the same run-to-completion model and the same microtask rule, but its loop is organised into phases handled by libuv. Each turn moves through phases in a fixed order: timers (setTimeout, setInterval), then poll (I/O callbacks), then check (setImmediate), then close callbacks.

Two Node-specific queues sit above the ordinary microtask queue. Callbacks passed to process.nextTick run first, before promise microtasks, and both are drained between every phase and after every callback. That ordering explains a common Node gotcha:

setImmediate(() => console.log('immediate'))
Promise.resolve().then(() => console.log('promise'))
process.nextTick(() => console.log('nextTick'))
// nextTick
// promise
// immediate

process.nextTick wins, then the promise microtask, then the setImmediate task on the check phase.

How to read any snippet

Reduce it to three steps and apply them in order. Run all synchronous code to the bottom first. Then drain every microtask, including ones queued during the drain. Then take one task from the task queue and repeat from the microtask step. Almost every "why did this log in that order" question answers itself once you apply those three steps in that sequence, and it is the same reasoning that makes async/await and closures inside callbacks behave the way they do.