Asynchronous JavaScript · Lesson 49 of 77 · concept

The event loop and the job queue

About 60 minutes.

By the end of this lesson you can

  • Describe the call stack, and explain what "runs to completion" means for a JavaScript function.
  • Explain how JavaScript can be waiting for many things at once while running one line at a time.
  • Put ordinary code, tasks and microtasks into the right order for a small program.
  • Explain what "blocking" means and what it costs.
  • Say which part of this machinery is the JavaScript language and which part is the host environment.

Read, not run. The sandbox on this site has no promise callbacks (nothing after a .then runs), timers such as setTimeout, so the examples in this lesson are shown rather than executed. Where an example cannot run, it says so and explains why instead of leaving you to find out.

One worker, one desk

Explanation

JavaScript runs your code on a single thread. There is one worker, and the worker does one thing at a time. That is not a limitation somebody forgot to fix — it is the deal that means you never have to worry about two parts of your program changing the same variable at the same instant.

The worker keeps a stack of what it is currently doing, called the call stack. Calling a function pushes a new sheet on top of the pile: this function, its arguments, its local variables, and the line to return to. When the function returns, the sheet is thrown away and the worker carries on with the sheet underneath.

So at any moment the stack tells you the whole story of "how did we get here". That is exactly what you are reading when a stack trace appears in an error message: the pile of unfinished calls, most recent at the top.

One chef, a row of timers

An analogy

Picture a kitchen with exactly one chef. The chef can only do one action at a time — chop, stir, plate. But the kitchen also has an oven with a timer, a kettle, and a hatch where deliveries arrive.

The chef puts a tray in the oven and sets the timer. They do not stand and watch it. They go back to chopping. When the timer pings, they finish the chop they are on — they never abandon it half-way — and then deal with the oven.

Everything about asynchronous JavaScript is in that picture. The oven, kettle and hatch are the host environment: timers, network requests, file reads. They do their waiting elsewhere. The chef is your single thread, and the pings pile up in a queue until the chef is free. Nothing ever interrupts the chef mid-chop.

Run to completion

Explanation

"Never abandon a chop half-way" has a name: run to completion. Once a piece of JavaScript starts, it runs all the way to the end before anything else gets a turn. No timer, no click handler and no promise callback can cut in halfway through your function.

This is why the ordering rules in this module are learnable at all. You only need to ask what happens between pieces of code, never inside one. And it is why the shared-state bugs that plague threaded languages mostly do not exist here — while your function runs, nothing else can touch what it is touching.

The bill for that guarantee arrives in the next section: if nothing can interrupt you, then a slow piece of code holds everything up.

Two queues, not one

Explanation

When the stack empties, the worker looks for queued work. There are two queues, and the difference between them decides almost every ordering puzzle you will meet.

The task queue (older material calls these macrotasks) holds things like timer callbacks and event handlers. One task is taken and run to completion per turn of the loop.

The microtask queue holds jobs — and promise reactions are jobs. MDN's definition is precise: a microtask runs after the function or program that created it exits, only when the JavaScript stack is empty, and before control goes back to the event loop. The crucial extra rule is that the microtask queue is drained until it is empty, so a microtask that queues another microtask gets it run before the next task, not after.

That gives the ordering you will see quoted everywhere: all of the current synchronous code, then every pending microtask, then one task, then microtasks again, and so on. A `.then` callback beats a `setTimeout(..., 0)` callback every time, even though the timer asked for zero delay.

The loop, in five steps

A mental model

1. Run the current piece of code, top to bottom, to completion. Anything asynchronous it starts is handed to the host and simply booked; nothing waits.

2. The stack is now empty. Drain the microtask queue completely, running each job to completion. If those jobs queue more microtasks, drain those too.

3. Take one task from the task queue and run it to completion.

4. Drain the microtask queue again.

5. Repeat from step 3, forever.

Keep this list next to any ordering question and answer it mechanically. Where does this callback go — microtask queue or task queue? Is the stack empty yet? That is the whole method, and it beats intuition every time.

Three wrong pictures

A common mistake

"Asynchronous means it runs in the background at the same time." Your JavaScript never runs at the same time as your other JavaScript. What happens elsewhere is the waiting — the operating system watching a socket, the browser counting down a timer. When the answer is ready your callback joins a queue and waits for its turn on the one thread.

"`setTimeout(fn, 0)` runs `fn` immediately." It queues a task. Every pending microtask runs first, and the current code has to finish first as well. The number is a minimum delay before it becomes eligible, never a promise about when.

"The event loop is part of JavaScript." It is not. ECMAScript, the language standard, defines jobs and leaves the scheduling to whoever is hosting it; browsers define their event loop in the HTML standard, and Node.js defines its own. This is why the same promise ordering rules hold everywhere but timer behaviour differs between a browser and a server.

Blocking is a real cost

Why it matters

Run-to-completion plus one thread means that a function that takes 300 ms freezes everything for 300 ms. In a browser that is 300 ms with no scrolling, no typing, no clicking, and no animation — the page looks broken, because as far as the user can tell it is.

This is the practical reason asynchronous code exists. Not elegance: a running program that stays responsive. Any operation that involves waiting for something outside the program — a server, a disk, a person — is handed off so the thread stays free.

It also tells you what asynchronous code cannot fix. If your code is slow because it is doing a million calculations, wrapping it in a promise changes nothing: the calculations still occupy the one thread. Asynchronous helps with waiting, not with working.

Recap

Recap

One thread, one call stack, and every piece of code runs to completion. Waiting happens outside your code; finished work comes back as queued callbacks. Microtasks (promise jobs) drain completely before the next task, so `.then` beats `setTimeout(..., 0)`. The event loop belongs to the host, not to the language. And because nothing can interrupt you, slow synchronous code freezes the lot.

Worked examples

Every output below was produced by actually running the code in the same sandbox your exercises run in — it is not a prediction.

The call stack, visible in the output

Before anything asynchronous, see the stack itself. Each call has to finish before the caller can continue — the indentation in the output is the depth of the pile.

1function inner() {2  console.log("  inner: start");3  console.log("  inner: done");4  return 2;5}6 7function outer() {8  console.log("outer: start");9  const value = inner() + 1;10  console.log("outer: done, value is " + value);11  return value;12}13 14console.log("program: start");15outer();16console.log("program: done");17 
  • line 9

    `outer` is suspended here — its sheet is still on the stack — while `inner` runs on top of it. It cannot continue until `inner` returns a value to add 1 to.

  • line 16

    This cannot print until `outer` has completely finished. That is run-to-completion, and it is the rule the rest of the module is built on.

What it prints

program: startouter: start  inner: start  inner: doneouter: done, value is 3program: done

What blocking actually looks like

Two hundred thousand additions, done synchronously. Nothing in the whole program can happen between A and B — in a browser that would be a frozen page, in a server a request nobody is answering.

1function addUpTo(limit) {2  let total = 0;3  for (let index = 0; index < limit; index++) {4    total = total + index;5  }6  return total;7}8 9console.log("A");10const total = addUpTo(200000);11console.log("B — nothing else could run between A and B");12console.log("the total is " + total);13 
  • line 10

    The whole thread is inside this call. Promises cannot help here: this is work, not waiting, and the one thread has to do it either way.

What it prints

AB — nothing else could run between A and Bthe total is 19999900000

Build the microtask queue by hand

The real job queue is invisible, so here is one you can read: an array of functions, plus a drain loop. The ordering it produces is exactly the ordering promises give you — including the rule that a job queued from inside a job still runs in the same drain.

1const queue = [];2 3function later(job) {4  queue.push(job);5}6 7function drain() {8  while (queue.length > 0) {9    const job = queue.shift();10    job();11  }12}13 14console.log("main: start");15later(function () {16  console.log("job 1");17  later(function () {18    console.log("job 3, queued by job 1 — still runs in this drain");19  });20});21later(function () {22  console.log("job 2");23});24console.log("main: end");25drain();26console.log("queue is now empty: " + (queue.length === 0));27 
  • line 8

    `while (queue.length > 0)` rather than a fixed count. That single choice is what makes jobs queued during the drain run in the same drain — the real microtask rule.

  • line 15

    `later` only stores the function. Nothing runs yet, which is why "main: end" prints before any job.

  • line 25

    Line 25 is the point where the stack empties in the real engine. Everything queued now runs, in the order it was queued.

What it prints

main: startmain: endjob 1job 2job 3, queued by job 1 — still runs in this drainqueue is now empty: true

Microtask before task, every time

The ordering puzzle you will be asked in interviews and, more usefully, the one that explains real bugs. Four numbered lines, deliberately written out of order in the source so that reading top to bottom gives you the wrong answer.

1console.log("1 — plain synchronous code");2 3setTimeout(function () {4  console.log("4 — a task; runs last, even with a delay of 0");5}, 0);6 7Promise.resolve().then(function () {8  console.log("3 — a microtask; runs before any task");9});10 11console.log("2 — still the same synchronous run");12 
  • line 3

    Booked as a task. The 0 is a minimum delay before it becomes eligible, not an instruction to run now.

  • line 7

    The promise is already fulfilled, and the callback STILL does not run here — it is queued as a microtask. MDN states that functions passed to then() are never called synchronously.

  • line 11

    Both lines 1 and 2 print before anything queued, because the current code always runs to completion first.

This one does not run hereNeither half runs in this sandbox: there is no `setTimeout` at all, and the promise job queue is never drained, so the `.then` callback never fires. In a browser or Node the order is 1, 2, 3, 4.

Check yourself

Not scored, not stored. Getting one wrong is the useful part.

This is the queue idea with nothing hidden. Write down the four lines you expect, in order, before running it.

const jobs = [];console.log("A");jobs.push(function () {  console.log("C");});console.log("B");while (jobs.length > 0) {  const next = jobs.shift();  next();}console.log("D");

A script queues a `setTimeout(fn, 0)` callback and, on the next line, a `.then` callback on an already-fulfilled promise. Which runs first in a browser?

Where is the event loop actually defined?

A function spends 400 ms doing arithmetic and freezes the page. Does wrapping it in a promise fix the freeze?

No exercise in this lesson

Why there is nothing to run here

Ordering is the whole subject, and this sandbox cannot show it: no timers, and the promise job queue is never drained (spec items RP-1, RP-2). A synchronous test would pass without proving anything about the event loop, which is worse than no test at all.

Worth remembering

  • What does "run to completion" guarantee?

    Once a piece of JavaScript starts, it finishes before anything else runs. No timer, event handler or promise callback can interrupt it halfway. Ordering questions are therefore only ever about what happens between pieces of code, never inside one.

  • A `.then` callback and a `setTimeout(fn, 0)` callback are both pending. Which runs first?

    The `.then` callback. Promise reactions are microtasks, and the microtask queue is drained completely — including microtasks queued during the drain — before the next task is taken.

  • Is the event loop part of the JavaScript language?

    No. ECMAScript defines jobs and hands scheduling to the host. Browsers specify their event loop in the HTML standard and Node.js has its own, which is why promise ordering is identical everywhere but timer behaviour is not.

Check this lesson against the source

We wrote the explanation; we did not invent the facts. This is the page that backs them.

MDN — Using microtasks in JavaScript with queueMicrotask()

Check there: That a microtask runs after the function or program that created it exits, only when the JavaScript execution stack is empty, and before control returns to the event loop; and that the microtask queue keeps being drained until empty, so microtasks queued by microtasks run before the next task.

WHATWG HTML Standard — Event loops

Check there: That the event loop is specified by the host environment (the HTML standard), not by the JavaScript language.

ECMAScript — Jobs and Host Operations to Enqueue Jobs

Check there: That the language defines jobs and hands scheduling to the host via HostEnqueuePromiseJob.

MDN — JavaScript execution model

Check there: The stack, execution contexts, and run-to-completion semantics.

Prefer to read the source in a structured breakdown? Turn this doc into a breakdown

Consent version 2026-07-31.1

Cookie preferences