The Event Loop

A tutorial on the JavaScript event loop, written for someone who has never needed the word "task". It opens from first principles — why one thread, what "runs later" physically means — then runs fourteen short programs through the loop's own parts, each with why it was designed that way, the words it uses defined in place, and the spec section or the snippet and output behind every claim. Plus a millisecond timeline, Node's libuv phases, and a sources view collecting all of it.

A frontend app built by Ananda Rizki. More of them at Labs.

The event loop in JavaScript

One thread. The loop decides what runs next.

JavaScript does one thing at a time. The event loop decides which thing.

This page assumes you can write JavaScript and have never needed the word “task”. Start at the top: the things you have already seen a page do, then why it is built the way it is, then the words. The 14 lessons after it take one short program each and run it through the machine, one step at a time.

YOU HAVE ALREADY SEEN THIS HAPPEN

You click a button and nothing happens. A second later, it happens.

A piece of work was already running. Your click could not interrupt it, so it waited in line.

A loading spinner appears, then stops spinning until the data arrives.

The spinner is animated by the same thread that is parsing the response. It is not stuck — it is not being drawn.

You type and the letters appear in bursts.

Each keystroke is a piece of work queued behind whatever else the page is doing between letters.

A page scrolls smoothly, then jerks, then is smooth again.

Something ran for longer than the gap between two screen refreshes, so some refreshes had nothing new to show.

Four different bugs, in four different codebases, and one mechanism underneath all of them. That mechanism is worth an hour.

WHY THERE HAS TO BE A LOOP AT ALL

  1. 01

    There is one worker

    Everything your page does in JavaScript — every line you wrote, every event handler, every animation frame, every piece of drawing — happens on one thread. One worker, doing one thing at a time. Not one thing quickly: one thing.

  2. 02

    Which is a decision, not a leftover

    Code in one window can reach straight into another window's objects, and the specification says that is exactly why it must all run in a single execution thread. Two threads touching the same page would need a lock on every property read. The platform bought safety, and paid for it in queueing.

  3. 03

    And nothing may interrupt it

    Once a piece of work starts, it runs to completion. Nothing pauses it halfway. That is why you can read a variable, do some work, and read it again knowing nobody moved it — and why a function that takes 300 ms takes 300 ms away from everything else.

  4. 04

    So work has to queue

    If the one worker is busy and something new arrives — a click, a timer coming due, a response from the network — the only thing that can happen is that it waits. Somewhere to wait is a queue. Something to decide who goes next is the event loop.

  5. 05

    That is the whole machine

    A worker, some queues, and a loop that takes one piece of work at a time. Everything else on this page is detail about which queue, and in what order.

WHAT “IT RUNS LATER” PHYSICALLY MEANS

“Runs later” does not mean “is paused”
setTimeout(f, 100) does not pause anything and does not run f. It returns immediately, having asked the browser to remember something. Your code carries straight on to the next line.
Someone else does the waiting
The counting-down, the listening for a click, the waiting on the network — none of that happens on your thread. It happens elsewhere in the browser, in code that is not JavaScript and is not competing with you.
The callback arrives as an entry in a list
When the wait is over, that other part of the browser does one small thing: it puts your function into a queue. That is all “the callback fired” physically means at that moment. Nothing has run yet.
Your thread looks at the list when it is free
And it is only free when the current piece of work has finished. That gap — between something landing in a queue and the thread being free to look — is where every late timer, every laggy button and every dropped frame lives.

Lesson 01 is this paragraph as a machine you can step through.

ONE TURN OF THE LOOP

  1. 01

    Take one task

    The loop picks a task queue that has something runnable in it, takes the first task, and hands it to the thread. A task is a coarse unit of work: run this script, dispatch this click, call this timer callback.

  2. 02

    Run it to completion

    The call stack grows and shrinks until it is empty. Nothing interrupts a task — not a timer that came due, not a click, not the browser wanting to draw. There is one thread and the task has it.

  3. 03

    Drain every microtask

    Then, before anything else, the microtask queue is emptied — including microtasks queued by the microtasks being run. Promise reactions live here, which is why a .then callback beats a timer scheduled before it. This step is called a microtask checkpoint, and it is also where unhandled promise rejections get noticed.

  4. 04

    Draw, if the screen is ready

    When the display is ready for a new frame — about every 16.7 ms at 60 Hz, and deliberately less often when the page cannot keep up — the browser gives itself a task that runs the animation-frame callbacks, then style, layout and paint. Not after every task: after the ones that finish near a frame boundary.

  5. 05

    Back to the top

    Or wait. An idle page is exactly this: an empty stack, empty queues, and a loop asking whether anything has arrived yet.

KEY CONCEPTS

Task
One piece of work the loop picks up and runs to the end. Running a script is one. Delivering a click is one. Calling a timer callback is one.Older writing calls it a macrotask. The specification just says task. The loop takes one per turn.
Microtask
A smaller piece of work that runs at the end of the current task rather than in a future one. Promise callbacks are microtasks.There is exactly one microtask queue, it is not a task queue, and it is emptied completely before the loop is allowed to move on.
The call stack
The list of functions that are part-way through running right now. Calling a function adds to it; returning removes from it.The loop only gets a turn when the stack is empty — which is why a slow function freezes everything.
Microtask checkpoint
The moment after a task finishes when the loop empties the microtask queue — including anything the microtasks themselves add.It is also when unhandled promise rejections are noticed, and when weakly-kept objects are released.
Task source
A label saying where a task came from — a click, a timer, a network response, the renderer. Tasks from the same source always keep their order.Each source is attached to a task queue, and a loop may have several queues. Which queue it serves next is up to the browser.
Updating the rendering
The part of the loop that turns your changed DOM into pixels: animation-frame callbacks first, then style, layout and paint.In the current standard this whole sequence is itself a task, on a task source called the rendering task source, which only the browser can queue.
Rendering opportunity
A moment when the browser is actually able to put new pixels on screen. That is when it queues the rendering work — not after every task.The rate is the browser's choice: roughly the refresh rate when the page keeps up, lower when it does not, and far lower for a background tab.
Starvation
Keeping the thread so busy with one kind of work that another kind never gets a turn at all.Microtasks can do it because their queue is drained to empty; tasks cannot, because the loop takes one and then moves on.
Long task
A task that occupied the thread for more than 50 milliseconds. Long enough that a click landing during it is visibly late.Measured as the task plus the microtask checkpoint that follows it, which is why a microtask drain counts against you.
Promise combinator
A function that takes several promises and gives you back one: Promise.all, Promise.allSettled, Promise.race, Promise.any. It watches them; it does not start them.Each one works by calling .then on every input, so it is subscription and nothing more — the work was already under way, and the combinator only decides what counts as an answer.
Settled
A promise is settled once it has an answer: either fulfilled with a value, or rejected with a reason. Before that it is pending, and a settled promise never changes again.Promise.allSettled is named for it — it waits for an answer of either kind, where Promise.all waits only for fulfilment and gives up at the first rejection.
Phase (Node)
Node's loop is divided into named stages — timers, poll, check and so on — each with its own queue, visited in a fixed order.Both the nextTick queue and the microtask queue are emptied between callbacks, whatever phase is current.

Every lesson carries the words it leans on, under Words, so none of this has to be memorised first.

WHAT THIS CHANGES IN YOUR CODE

Break long work into pieces

The loop can only draw between tasks, so 200 ms of work in one task costs every frame in that window. The same work split across twenty tasks lets a frame and any queued input in between the pieces. It finishes no sooner; the page just stays alive while it does. await scheduler.yield() is the purpose-built split, with a setTimeout promise as the fallback.

Watch the 50 ms line

A task over 50 ms is what the platform itself calls a long task, and it is the point at which a click can visibly wait. The same 50 ms is the cap on an idle callback's budget, described in the specification as the threshold of human perception. It is a useful budget to hold a single piece of work to.

Aim at 200 ms, end to end

INP — Interaction to Next Paint — measures from the user's gesture to the moment the page next paints, and 200 ms or less is the bar at the 75th percentile of real page loads. Three things share that budget: whatever was already running, your handler, and the frame that shows the result.

For real parallelism, leave the thread

async/await and Promise.all overlap waiting — two fetches in flight at once — and nothing more. Promise.all does not even do the overlapping: the calls that produced the promises already started the work, and the combinator only subscribes and counts. Actual computation on two cores at the same time needs a Web Worker, which is a second thread with its own loop.

Do not use a timer as a synchronisation trick

setTimeout(fn, 0) to "wait for the DOM" or to "let React finish" works by accident and breaks under load, because you are betting on queue order you do not control. Use the thing that actually names the moment: requestAnimationFrame before a paint, queueMicrotask for the end of this task, an effect for after a commit.

Handle errors on the right side of the boundary

A try/catch cannot reach into a callback that runs in a later task — it guards a stack, and that stack is gone. Handle inside the callback, use .catch on promises, and log unhandledrejection as well as window.onerror or half your production failures are invisible.

WHAT PEOPLE GET WRONG

“setTimeout(f, 0) runs f next.”

It queues a task. The rest of the current task runs first, then every microtask, and only then is your callback considered — and 0 is a floor, not a promise. Nested deeper than five, it is not even 0: the standard clamps it to 4 ms.

“The event loop is part of JavaScript.”

The language defines promise jobs and explicitly leaves the scheduling to its host. The loop belongs to the environment: the HTML specification in a browser, libuv in Node. Same language, different loops, different answers.

“There is a task queue, and the loop takes the oldest task in it.”

The specification says a task queue. A loop may keep several, grouped by where tasks come from, and it chooses between them in an implementation-defined way — which is how browsers keep clicks responsive on a busy page. Order is guaranteed within one source and nowhere else.

“Promises are slower, because they are asynchronous.”

A promise reaction is a microtask, so it runs before the loop may take another task. It is the earliest callback you can schedule, not the latest.

“async/await makes things run in parallel.”

It is still one thread. await yields the thread so something else can use it; it does not get you a second one. Parallelism needs a worker.

“Promise.all runs my promises in parallel.”

It runs nothing at all. The work started at the call that produced each promise; Promise.all attaches a .then to each one and counts down. Starting both and then awaiting both, with no combinator anywhere, is exactly as fast — and the whole thing is still one thread.

“A failed Promise.all stops the rest.”

It stops telling you about them. The other operations keep running, still have their effects, and their outcomes are discarded — a later rejection among them is not even reported, because the combinator attached a handler to every input when it subscribed. Cancelling is something you build, with an AbortSignal.

“Microtasks are lower priority than timers.”

The other way round. Microtasks drain before the loop is allowed to take the next task, so they run ahead of every timer, including ones already due.

“Rendering is not a task.”

It was described that way for years, and the practical part still holds: nothing interleaves with it and you cannot queue it yourself. But the current standard queues the whole rendering update as a task, on a rendering task source reserved for the browser — and a microtask checkpoint follows it like any other task.

“Splitting work into chunks makes it faster.”

It makes it slightly slower. What it buys is the gaps: the page can draw and answer a click between the pieces. The work finishes at the same time and the page stays alive while it does.

HOW TO CHECK ANY OF THIS

Every lesson has a Proof tab carrying the specification section or the snippet and output behind its claims, and view 17 collects all of them in one place. Nothing here is recalled — which matters most exactly where this subject is easiest to get confidently wrong.

The 14 lessons take one program each, and you can watch the stack and the queues do this.

Labs — by Ananda Rizki

Simulator

The Event Loop

An interactive tutorial on the JavaScript event loop, built so a beginner finishes understanding the why as well as the what. It starts from symptoms anyone has seen — a dead button, a spinner that stops spinning, text arriving in bursts — then explains why the platform has one thread at all and what a callback "arriving later" physically is. Fourteen short programs are then traced through the loop's own parts: a call stack, the microtask queue, the task queues, the animation-frame callbacks and the rendering steps, with the panel the loop is currently inside taking the accent border. They cover the stack running to empty, microtasks draining before any task, await as a microtask boundary desugared to .then, why try/catch cannot reach a later task and when an unhandled rejection is reported, what the promise combinators actually cost the loop — Promise.all as a subscription that starts nothing and answers two microtask turns after its inputs settle, the first rejection winning while the other work carries on uncancelled and unreported, and Promise.allSettled waiting for every outcome and never rejecting — rendering as work the browser queues for itself at a rendering opportunity, why a click waits behind a long task and how that becomes INP, microtask starvation, the 4 ms timer clamp, the fact that the spec says a task queue rather than the task queue, how to hand the loop back with scheduler.yield, and how Node differs. Two machine views measure the same loop against a 16.7 ms display frame and walk Node's libuv phases in the order they run since Node 20. Every lesson carries a Why panel, an in-place glossary and a Proof panel: the exact specification sentence — including the ECMAScript algorithms for PerformPromiseAll and PerformPromiseAllSettled that the tick counts are derived from — or the snippet that was run and the output it printed, in Chrome 152 and Node 24.

Built by Ananda Rizki, a frontend developer — one of the small web apps he writes for fun at Labs.