Skip to content
JavaScript

The JavaScript Event Loop, Explained

By The EbookWale Team · Updated July 28, 2026 · 8 min read

The event loop is what lets single-threaded JavaScript handle timers, promises and I/O: it runs one task, drains every microtask, then repeats.

The event loop is the scheduler that lets single-threaded JavaScript handle timers, network responses and user input at once: it runs one task until the call stack is empty, drains every queued microtask, then takes the next task and repeats.

Most explanations stop at a picture of the queues. Interviews do not ask you to draw the picture; they hand you six lines of code and ask what order they log in. That is a skill, and it is learnable. Every section below starts with a snippet. Guess the output before you read on, and by the end you will be getting them right for the right reason.

The short version: one task runs to completion, then all microtasks run (promise callbacks, await resumptions), then the browser may render, then the next task is taken.

What “single-threaded” actually means

JavaScript has one call stack, so it does exactly one thing at a time. When you call a function it is pushed onto the stack; when it returns it is popped. Nothing else runs until the stack is empty.

The part people miss: setTimeout, fetch and DOM events are not JavaScript. They are services the host environment provides. Your code hands the host a callback and returns immediately, the host does the waiting on its own threads, and when it is done it puts your callback in a queue. The event loop is the thing that moves callbacks out of those queues and onto the empty stack.

So JavaScript is single-threaded, but the runtime around it is not. That is the whole trick.

Predict this one first

console.log('script start');

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

Promise.resolve().then(() => console.log('promise'));

console.log('script end');
script start
script end
promise
timeout

The two synchronous logs go first, which is unsurprising. The interesting part is that promise beats timeout even though the timer was scheduled first and asked for zero delay.

That happens because there are two queues, not one, and they do not have equal status:

  • The task queue (also called the macrotask queue) holds whole units of work: the initial script, timer callbacks, DOM event callbacks, network callbacks.
  • The microtask queue holds promise reactions and anything passed to queueMicrotask().

The event loop takes one task, runs it to completion, and then empties the entire microtask queue before it will even look at the task queue again.

Run one taskuntil stack is emptyDrain microtasksevery single oneNext taskthen repeat
One task, then every microtask, then the next task.

In the snippet, the initial script is the current task. The promise callback is a microtask queued during that task, so it runs at the checkpoint the moment the script finishes. The timer callback is a separate task and has to wait its turn.

Why “drain completely” is the load-bearing word

Microtasks queued by microtasks are handled in the same checkpoint. The loop does not process a fixed batch; it keeps going until the queue is genuinely empty.

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

Promise.resolve()
  .then(() => console.log('microtask 1'))
  .then(() => console.log('microtask 2'))
  .then(() => console.log('microtask 3'));
microtask 1
microtask 2
microtask 3
timeout

Only the first .then is queued while the script runs. The second is queued by the first, the third by the second. All three still finish before the timer, because each one was added while the checkpoint was still draining.

🔑 REMEMBER —

Microtasks drain completely before the next task is taken, including microtasks queued by other microtasks. A promise chain of any length beats a setTimeout(fn, 0) scheduled before it.

This also settles what setTimeout(fn, 0) really means. You are not asking for “now”. You are asking the runtime to queue the callback as a new task, which means: after the current script, and after every microtask that script produced, however many that turns out to be. The delay you pass is a minimum wait before the callback becomes eligible, never a promise about when it runs.

Why await resumes in a microtask

This is the one that catches people out, and it comes up constantly in JavaScript interviews.

async function getName() {
  return 'Ada';
}

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

async function main() {
  console.log('before await');
  const name = await getName();
  console.log('after await:', name);
}

main();

console.log('synchronous end');
before await
synchronous end
after await: Ada
timeout

An async function runs synchronously until it hits its first await. That is why before await prints immediately. At the await, the function suspends and everything below it is registered as a continuation on the awaited promise.

Here is the key point: that continuation is a promise reaction, so it resumes as a microtask. It is not a new task. So after await runs at the checkpoint right after the script ends, comfortably ahead of the timer, even though the timer was scheduled earlier.

⚠️ GOTCHA —

await does not “pause and let other things run” in the way people picture. It yields to the microtask queue, not to the task queue. Timers, clicks and rendering all still wait behind it.

Once you see await as syntax over promise callbacks, the ordering stops being arbitrary. If that connection is shaky, Promises and async/await, explained covers the layer underneath.

Why the same code logs differently in Node

Copy a browser snippet into Node and the answer can change, because Node has an extra queue that ranks above promises.

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

Promise.resolve().then(() => console.log('promise'));

process.nextTick(() => console.log('nextTick'));

console.log('synchronous');
synchronous
nextTick
promise
timeout

process.nextTick callbacks run before promise microtasks. The nextTick queue is not really part of the loop at all: Node drains it after the current operation finishes, whichever phase the loop is in.

Node also splits the task side into phases that it cycles through, rather than one flat queue. The three you will actually be asked about:

PhaseRunsNotable
timerssetTimeout and setInterval callbacksfires when the delay has elapsed
pollI/O callbacks (files, sockets)where the loop waits when idle
checksetImmediate callbacksalways after poll, in the same turn

Both queues (nextTick, then promises) are drained between phases, not only between full turns. One practical consequence: at the top level of a program, the order of setTimeout(fn, 0) and setImmediate(fn) is not guaranteed, because it depends on how long the process took to start. Inside an I/O callback it is deterministic, because you are already past the poll phase and check comes next.

What a frozen tab actually is

In the browser, rendering is something the event loop does between tasks, after microtasks have drained. It cannot happen while a task is running, because the task owns the only thread.

That gives you a precise definition of a janky page: any single task that takes too long. A heavy loop over a large array, a giant synchronous JSON parse, a layout thrash inside a scroll handler. While it runs, nothing paints and no input is processed.

An endless microtask chain is worse, because it never even reaches the point where rendering is allowed:

let ticks = 0;

function chain() {
  ticks++;
  if (ticks < 1000) queueMicrotask(chain);
}

setTimeout(() => console.log('timer ran after', ticks, 'microtasks'), 0);

queueMicrotask(chain);

console.log('synchronous code done');
synchronous code done
timer ran after 1000 microtasks

The timer was queued before the chain started and still had to wait for all thousand microtasks. Remove the ticks < 1000 guard and the timer never runs at all: the page locks up permanently, with no infinite for loop anywhere in sight.

💡 TIP —

To keep a long job from blocking paint, split it into chunks and schedule each chunk as a task (setTimeout(chunk, 0)), not a microtask. Tasks give the browser a gap to render in; microtasks do not.

A mental model that sticks

Think of a single barista at a counter.

A task is one customer’s order, served start to finish. A microtask is that same customer saying “oh, and one more thing” while still standing there, and then again, and again. The barista handles every follow-up before calling the next customer, because the person in front of them has not left yet.

Rendering is wiping the counter down. It only happens between customers. A customer who keeps adding to their order forever means the counter is never wiped and the queue never moves, which is exactly what a frozen tab is.

Common mistakes

  • Reading setTimeout(fn, 0) as “run now”. It means “run in a future task”, which is strictly after the current script and all its microtasks.
  • Expecting await to let a timer through. It resumes as a microtask, so it jumps the queue ahead of every pending task.
  • Assuming a promise chain yields between links. It does not. The whole chain is one drain, and the browser cannot paint in the middle of it.
  • Testing async ordering in Node and shipping the conclusion to the browser. process.nextTick and the phase structure have no browser equivalent.
  • Blaming “slow JavaScript” for a frozen UI. The usual cause is one task that is too long, not code that is too slow overall.
  • Confusing the event loop with this binding. Ordering and binding are separate mechanisms, though callbacks trip people on both; see the this keyword, finally explained.

Where the books fit

The event loop sits alongside closures and this in the “tricky internals” phase of our JavaScript roadmap. It is the concept that makes asynchronous code stop feeling like guesswork, so it pays to learn it before you learn a framework, not after.

If you want this worked through with the queues drawn out and a stack of predict-the-output drills, that is the depth in JavaScript in Three Months, our job-ready tier. Starting from scratch and just want promises and timers to behave? JavaScript in One Month gets you there with worked examples. And if you are preparing for senior or staff interviews, where the questions move on to task scheduling, starvation and keeping a main thread responsive under load, JavaScript for Staff Engineers picks up where this leaves off.

Get to the point where you can read six lines and call the order out loud, and asynchronous JavaScript stops being a category of bug.

Frequently asked questions

What is the JavaScript event loop?

The event loop is the scheduler that decides what JavaScript runs next. It takes one task, runs it until the call stack is empty, then runs every queued microtask before taking the next task. That single rule explains almost every surprising ordering in asynchronous JavaScript.

Why does setTimeout(fn, 0) not run immediately?

A zero delay asks the runtime to queue the callback as a new task, not to run it now. The current script has to finish first, and every microtask that script queued has to drain first as well. The number you pass is a minimum wait, never a guarantee of when the callback runs.

Do promise callbacks run before setTimeout callbacks?

Yes, when both are queued during the same task. Promise callbacks go on the microtask queue, which the event loop empties completely before it takes the next task. So a promise chain finishes ahead of a timer that was scheduled before it.

Why does code after await run before a setTimeout?

Everything after an await is packaged as a promise continuation, so it resumes as a microtask rather than as a new task. Microtasks all run before the next task is taken, which puts the resumed code ahead of any timer callback. This is the most common trip-up in event loop interview questions.

Is the event loop the same in Node.js and the browser?

The core rule is identical: run a task, then drain all microtasks. Node adds process.nextTick, whose callbacks run before promise microtasks, and organises its work into phases such as timers, poll and check. The browser gets a chance to render between tasks, which Node has no equivalent for.