The event loop
Understand the JavaScript event loop: the call stack, task queue, microtask queue, rendering steps, and how starvation freezes the page.
- 01Which line runs nextPut normal lines, timer tasks, and microtasks in the order they run.
- 02When the browser can paintExplain why three style changes in one task show only the last one on screen.
- 03Why starvation freezes pagesSpot code that keeps adding microtasks, and split the work into tasks instead.
The event loop in one picture
On a web page, JavaScript runs your code on one main thread. The main thread is the one worker that runs your code. The same worker also draws the page. It can do only one job at a time.
Some jobs have to wait: a timer, a click, a reply from a server. While they wait, none of your code is running. The event loop is the set of rules that picks which waiting job runs next.
Here is the whole rule in one sentence: the event loop runs one task, then runs every waiting microtask, then gives the browser a chance to paint the page, then starts the next task. The next sections explain what a task and a microtask are. Press Play to see one turn:
The event loop takes the oldest task, such as a script, a timer callback or a click, and runs it until the call stack is empty.
Browsers and other programs that run JavaScript differ in small details. Stage 11 covers the browser's full rules. This lesson gives you enough to explain real "what prints first?" puzzles today.
A shop has one cashier. The cashier serves one customer at a time. After each customer from the regular line, the cashier serves everyone in the express line. Then the price display can update. Then the next customer from the regular line steps up.
- In real life: The cashier serves one customer at a time
- In JavaScript: The call stack: the code that is running now
- In real life: The regular line
- In JavaScript: The task queue: timers, clicks, and the start of a script
- In real life: The express line
- In JavaScript: The microtask queue: promise callbacks and
queueMicrotaskcallbacks - In real life: The price display updates between customers
- In JavaScript: The browser paints the page between turns
Promises are taught in Module 22. For now, you need only two facts:
Promise.resolve().then(callback)is one common way to putcallbackin the microtask queue.queueMicrotask(callback)is the direct way to do the same.
This lesson goes further than the Runtime at a glance lesson. It has four steps:
- The call stack and the task queue.
- The microtask queue.
- How the browser paints the page.
- Starvation: when some work never gets a turn.
The call stack & task queue
STEP THROUGHThe call stack is the list of functions that are running right now. You met it in The call stack lesson. When one function calls another, the new one goes on top. When it returns, it comes off the top.
The event loop has one key rule here, called run to completion. Once a task starts, it runs until the call stack is empty. No other task can jump in halfway.
A task is one job that waits in the main line, the task queue. A queue is a waiting line: the first one in is the first one out. These jobs are all tasks:
- Running a script file.
- Calling a timer's callback.
- Handling a click.
Your code doesn't watch the clock or the mouse. Other parts of the browser do. When a timer is due or you click, they put a task in the queue, and wait for the event loop to run it:
A callback is a function you hand over, to be called later. In setTimeout(fn, 0), fn is the callback. It does not run right away. It waits in the task queue as a later task. That is why a timer with a delay of 0 still waits for the current script to finish.
Code that runs right away, line by line, is called synchronous code. The console.log lines below are synchronous. The callbacks are not.
Watch it happen. The visualizer below runs real code in a small, simulated browser. Press Play, and follow each call onto the call stack. Timers and click listeners wait in the browser, and when a timer is done, its callback moves to the task queue. The event loop moves it to the call stack only when the stack is empty.
The script starts. Running a script is the first task, so it goes on the call stack.
const button = document.querySelector("button");button.addEventListener("click", function onClick() {setTimeout(function timer() {console.log("You clicked the button!");}, 2000);});console.log("Hi!");setTimeout(function timeout() {console.log("Click the button!");}, 5000);console.log("Welcome to the event loop.");
nothing printed yet
script
holding nothing
empty
empty
When the program is done, click Click me! on its page. Your click becomes a task too: onClick runs and starts a 2-second timer. Press Edit code to change anything, or pick another program.
console.log("start");setTimeout(() => console.log("timer"), 0);queueMicrotask(() => console.log("microtask"));console.log("end");Pick A timer and a microtask in the visualizer to run this one. Line 2's timer is done at once, so its callback moves to the task queue right away. It is ready to run, but ready is not the same as running. Line 4 still prints first, because the current task must finish. After that, waiting microtasks get their turn before the next task.
The tiny puzzle above, line by line:
- Line 1 prints
startright away. - Line 2 puts the timer callback in the task queue.
- Line 3 puts a callback in the microtask queue.
- Line 4 prints
end. Neither callback can cut in before this line.
So the final order is start, end, microtask, timer.
button.addEventListener("click", () => { for (let item = 0; item < 100000000; item += 1) { doSmallPiece(item); }});A click handler is a task too. Say it runs a long loop like this one. While the loop runs, it holds the main thread:
- Another click cannot cut in.
- The browser cannot paint the page.
The fix: do a small piece of work, then stop. Schedule the next piece as a later task.
A meeting has one talking stick. Only the person holding it may speak. They finish their sentence before they pass the stick on. Others wait in line. Nobody cuts in halfway through a word. JavaScript tasks work the same way.
- In real life: The person holding the stick
- In JavaScript: The code on the call stack right now
- In real life: People waiting to speak
- In JavaScript: The task queue
- In real life: Nobody cuts in mid-sentence
- In JavaScript: Run to completion
The microtask queue
INTERACTIVEA microtask is a small callback that waits in a second line, the microtask queue. This line goes before the task queue.
Microtasks run at a microtask checkpoint. That is the moment right after a task's code finishes. At a checkpoint, the event loop does not run just one microtask. It keeps running microtasks until the microtask queue is empty.
That is why this common pattern surprises people:
- Synchronous lines, like
console.log, print first. - Promise callbacks and
queueMicrotaskcallbacks print next. - Timer callbacks print last.
setTimeout(() => { console.log("first task"); queueMicrotask(() => console.log("microtask after first"));}, 0);setTimeout(() => console.log("second task"), 0);Here, each setTimeout puts one task in the queue. Step by step:
- Lines 1 to 4 queue the first timer. Line 5 queues the second timer.
- The first timer runs. Line 2 prints
first task. - Line 3 puts a microtask in the queue.
- The first task ends, so a checkpoint comes. The microtask prints
microtask after first. - Only then does the second timer run and print
second task.
A checkpoint comes after every task, not only after the first script. On a timeline of the main thread, it looks like this:
Now watch the microtask queue fill up and empty. Press Play, and keep an eye on the two queues: after the script, the event loop runs every microtask before it takes the timer from the task queue. Try the other programs too, such as Each task gets a checkpoint.
The script starts. Running a script is the first task, so it goes on the call stack.
console.log("script start");setTimeout(() => console.log("timeout task"), 0);Promise.resolve().then(() => console.log("promise microtask"));queueMicrotask(() => console.log("queued microtask"));console.log("script end");
nothing printed yet
script
holding nothing
empty
empty
The table below compares the queues. Read each row as: this kind of work waits here, and it runs at this time.
| Queue or step | What waits there | When it runs |
|---|---|---|
| Call stack | The function that is running right now. | Now, until it returns. |
| Task queue | The start of a script, timer callbacks, clicks, and messages. | One task per turn of the loop. |
| Microtask queue | Promise callbacks, queueMicrotask callbacks, and MutationObserver callbacks. | After each task, until the queue is empty. |
| Chance to render | Style, layout, and paint. requestAnimationFrame callbacks run just before the paint. | Between tasks, after all microtasks. |
A microtask can put another microtask in the queue. The new one joins the end of the same line. It still runs before the next task. This is useful for a tiny follow-up job right after the current code. It is risky if you keep adding more and more.
- setTimeout callback
- click handler
- Promise.resolve().then callback
- queueMicrotask callback
- MutationObserver callback
- requestAnimationFrame callback
Sort each callback by where it waits. requestAnimationFrame is here because people often mix it up with both queues.
Rendering steps
REAL BROWSERRendering means the browser draws the page on the screen. It has three steps:
- Style: work out each element's colors and sizes.
- Layout: work out where each element goes.
- Paint: draw the pixels.
JavaScript and rendering share the same main thread. When your code changes an element's style, the browser notes the change. But it cannot draw the change while your task is running.
After the task ends and the microtask queue is empty, the browser gets a chance to render. It may update the screen then. It does not have to draw a new frame for every small change. A frame is one picture of the page on the screen.
requestAnimationFrame(callback) fits in here. The browser calls callback just before it paints a frame. It is not a task, and it is not a microtask. Use it for animation code that gets the next frame ready.
Sometimes you want clicks and painting to happen between pieces of work. For that, use later tasks instead, such as setTimeout.
box.style.background = "red";box.style.background = "gold";box.style.background = "lime";// The browser can paint only after this task ends. requestAnimationFrame(() => { box.style.background = "red"; requestAnimationFrame(() => { box.style.background = "gold"; setTimeout(() => { box.style.background = "lime"; }, 0); });});- Ready: click a run button.
Inside one task, the browser notes every style change, but it usually paints only after the task and its microtasks finish. requestAnimationFrame runs just before a paint. setTimeout schedules a later task.
The All in one task button sets red, then gold, then lime, all in one task. The browser gets its chance to paint only after that. By then the box is already lime.
lime.The Spread over several turns button sets red now, gold in an animation-frame callback, and lime in a later timer task. So the browser gets a separate chance to paint each color.
In the first run, all three colors are set inside one task. When the browser can finally paint, the box's color is the last one set. The earlier colors did exist in JavaScript. They just never got drawn on the screen.
A chance to render does not promise a new frame right away. The browser may skip or delay painting:
- when the tab is hidden,
- when the device is busy,
- when nothing on the page changed.
Use requestAnimationFrame to line your work up with a possible frame. It cannot force a frame to appear.
Follow one click
LIVENow put the pieces together with one real event. Here is a click listener for a Save button:
button.addEventListener("click", () => {
console.log("click handler");
button.textContent = "Saved";
Promise.resolve().then(() => console.log("microtask"));
console.log("handler ends");
});Line 1 doesn't run the listener. It hands the function to the browser, to call later, every time someone clicks. When you click Save, line 2 prints click handler, line 5 prints handler ends, and then the callback from line 4 prints microtask.
Before any of that, the click makes a short trip. In the visualizer, press Play to run the script, then click Save on its page and watch your click travel:
The script starts. Running a script is the first task, so it goes on the call stack.
const button = document.querySelector("#save");button.addEventListener("click", () => {console.log("click handler");button.textContent = "Saved";Promise.resolve().then(() => console.log("microtask"));console.log("handler ends");});
nothing printed yet
script
holding nothing
empty
empty
Three things to notice:
- The browser, not your code, notices the click and queues the task.
- The new text exists as soon as line 3 runs. You see it only after the task and its microtasks finish.
- If another task is running when you click, your click waits in the task queue.
Try the last one: pick Clicking a busy page, press Play, and click Save while busy(3000) holds the call stack. Your click joins the task queue at once, but it waits there until the slow work ends. That is what a page that "doesn't respond" feels like, and a slow listener does the same to the clicks after it.
A real click goes through the task queue. But when your own code calls button.click() or dispatchEvent, the listeners run right away, inside the task that is already running, like a normal function call.
Starvation
SAFE DEMOStarvation means one kind of work keeps getting turns, so other work never gets one. In the cashier story, the express line never ends. The regular line waits, and the price display waits too. On a page, timers, clicks, and painting all seem frozen.
The risky shape is a microtask that queues another microtask, over and over. The browser must empty the microtask queue before it can run the next task or paint.
The demo below is safe: it always stops after the number of steps you choose. Then it shows how long a waiting timer had to wait. Compare that with splitting the same work into later tasks.
setTimeout(() => console.log("timer"), 0);let count = 0;function spinMicrotasks(limit) { count = count + 1; if (count < limit) { queueMicrotask(() => spinMicrotasks(limit)); }}queueMicrotask(() => spinMicrotasks(50000));for (let i = 0; i < items.length; i += 1) { doSmallPiece(items[i]); if (i % 100 === 99) { setTimeout(processNextChunk, 0); return; }}Choose a mode, then run the demo. It always stops.
The demo always stops. In the microtask chain, the waiting timer must wait until the chain ends. When the work is split into tasks, the timer gets a turn between the pieces.
let item = 1;function processChunk() { console.log("item", item); item += 1; if (item <= 3) setTimeout(processChunk, 0);}processChunk();This example prints item 1, item 2, and item 3, each in its own task. Each call does a little work. Then it schedules the next call with setTimeout. Between tasks, clicks and painting get a turn. They don't have to wait behind one giant loop.
Use microtasks for short follow-up jobs that must happen right away. Don't use them for long loops, for animation, or for work that should let clicks and painting happen. Split heavy work into tasks. For very heavy jobs, later lessons show workers, which run code off the main thread.
Where you'll use this
When an output order surprises you, ask four questions:
- Which lines are synchronous?
- Which callbacks are tasks?
- Which callbacks are microtasks?
- When can the browser paint?
Each row below is a common situation. Read it as: when you want this, use this, and here is why it works.
| What you want | What to use | Why it works |
|---|---|---|
| Show a spinner before slow work | Start the slow work in a later task. | The browser needs a chance to paint the spinner first. |
| Run a tiny cleanup right after the current function | queueMicrotask(cleanup) | It runs before the next task, without waiting for a timer. |
| Get the next animation frame ready | requestAnimationFrame(draw) | The callback runs just before the browser paints. |
| Handle thousands of items without freezing the page | Split the work into several tasks. | Between the pieces, clicks and painting get a turn. |
Say you set loading = true and then start a huge synchronous loop right away. The spinner may never appear, because the browser cannot paint until the loop ends. Give the browser a turn first. Then do the heavy work in pieces.
async function yieldToBrowser() { if (globalThis.scheduler?.yield) { await globalThis.scheduler.yield(); return; } await new Promise((resolve) => setTimeout(resolve, 0));}scheduler.yield() is a newer way to give the browser a turn. Not every browser has it yet, so line 2 checks for it first. Checking before you use a feature is called feature detection.
- If the browser has it, line 3 waits for
scheduler.yield(). - If not, line 6 waits for a
setTimeoutinstead. That works in every browser, but its timing is less exact.
Both ways start a later turn. The later turn is what matters, not the exact delay.
This lesson uses the browser's rules, because clicks, painting, and requestAnimationFrame exist only in the browser. Node also runs JavaScript on one thread and has microtask checkpoints. But its event loop goes through named stages, called phases, such as timers and poll. See the Node event-loop lesson for those rules.
Common misconceptions
“setTimeout(fn, 0) runs right away.”
It puts a task in the queue for later. First the current code finishes. Then every microtask runs. Then the browser may paint. Only then can the timer task run.
“Promises make code run in parallel.”
A promise callback is a microtask. It runs on the same main thread as everything else. It does not split your code across several processor cores.
“One microtask runs after each task.”
After a task, the whole microtask queue runs until it is empty. That includes new microtasks added by earlier ones.
“The browser paints every style change.”
The browser paints between turns. If you set a style several times in one task, you usually see only the last value.
“requestAnimationFrame is just a faster setTimeout.”
It runs at a different moment: just before a paint. It never waits in the task queue.
console.log("start");setTimeout(() => console.log("timer"), 0);Promise.resolve().then(() => console.log("promise"));console.log("end");The output is start, end, promise, timer. The timer is not late because the browser is slow. It waits in the task queue, because the event-loop rules say it must.
Practice: predict and fix event-loop code
5 EXERCISESWrite the order the console shows. Separate the letters with spaces or commas.
console.log("A");
setTimeout(() => console.log("B"), 0);
queueMicrotask(() => console.log("C"));
console.log("D");Line 1 prints A. Line 2 puts a task in the queue. Line 3 puts a microtask in the queue. Line 4 prints D. Then the microtask prints C. Last, the timer task prints B.
The spinner's state changes, but the user never sees it before the loop starts. The heavy work should wait for a later turn. Should it wait as a task or as a microtask?
showSpinner();
for (let i = 0; i < 100000000; i += 1) {
crunch(i);
}
hideSpinner();showSpinner();
setTimeout(() => {
processInChunks();
}, 0);Starting the heavy work in a later task gives the browser a chance to paint the spinner first. For very large work, split processInChunks into several later tasks too.
In one or two sentences, explain why a never-ending chain of microtasks can freeze clicks and painting.
After a task, the microtask queue must become empty before anything else runs. If each microtask queues another one, the queue never empties. So the next task and the browser's painting never get a turn.
Arrange a synchronous log, a microtask, and a timer so the output is exactly first second third.
// Target: first, second, third
setTimeout(() => console.log("third"), 0);
console.log("first");
queueMicrotask(() => console.log("second"));console.log("first");
queueMicrotask(() => console.log("second"));
setTimeout(() => console.log("third"), 0);first prints right away. The microtask prints second after the script ends. The timer task prints third last.
You need to run a tiny bookkeeping job right after the current function returns. It must happen before a waiting timer callback. Which one fits?
Use queueMicrotask. It runs after the current code finishes, but before the next task, such as a timer task. Use setTimeout instead when you want clicks and painting to get a turn first.
Quiz: check your understanding
8 QUESTIONSPredict the answer before you click. Each wrong answer tells you which queue rule it missed.
Question 1 of 8JavaScript is in the middle of running a function. What runs now?
Choose an answer to see the explanation.
Question 2 of 8What does this print?
Read the code, then predictconsole.log("one"); setTimeout(() => console.log("two"), 0); Promise.resolve().then(() => console.log("three")); console.log("four");Choose an answer to see the explanation.
Question 3 of 8Which callback is a microtask?
Choose an answer to see the explanation.
Question 4 of 8In this code, one microtask queues another. What does it print?
Read the code, then predictsetTimeout(() => console.log("task 1"), 0); queueMicrotask(() => { console.log("micro 1"); queueMicrotask(() => console.log("micro 2")); }); console.log("sync");Choose an answer to see the explanation.
Question 5 of 8When can the browser paint a style change?
Choose an answer to see the explanation.
Question 6 of 8What is microtask starvation?
Choose an answer to see the explanation.
Question 7 of 8You need to split a large page update into pieces, so clicks and painting can happen in between. Which way is safer?
Choose an answer to see the explanation.
Question 8 of 8A 2-second loop is running when you click a button. When does the button's click listener run?
Choose an answer to see the explanation.
Key takeaways
- JavaScript runs one task at a time. The current task runs to the end before anything else.
- Timer callbacks and clicks wait in the task queue. The browser queues them, and your listener runs only when the event loop takes the task.
- Promise callbacks,
queueMicrotaskcallbacks, and MutationObserver callbacks are microtasks. - After every task, all microtasks run until the microtask queue is empty. Then the next task can start.
- The browser can paint only between turns, after the microtasks are done.
requestAnimationFramecallbacks run just before a paint. - A chain of microtasks can starve tasks and painting. Split long page work into tasks.
The event loop in one line.
Run one task, run every microtask, paint if the browser can, then take the next task.
box.style.background = "red";box.style.background = "gold";box.style.background = "lime";// The browser can paint only after this task ends. requestAnimationFrame(() => { box.style.background = "red"; requestAnimationFrame(() => { box.style.background = "gold"; setTimeout(() => { box.style.background = "lime"; }, 0); });});Up next: Microtasks in depth. You will predict the order of promise, queueMicrotask, and await puzzles with fewer clues.