The Event Loop in Depth
In How Asynchronous Code Works you learned the basic rule: synchronous code runs first, then promise callbacks, then timers. That rule is enough for simple code. At work you will meet harder questions. Why does my "Loading..." message never appear? Why does the page freeze even though I used promises? Why does this test print lines in a strange order?
This lesson answers them. You will see exactly what one turn of the event loop does, where await fits in, when the browser actually redraws the screen, and how to split long work so the page stays responsive.
One turn of the loop
The browser keeps several queues of work waiting for the main thread. A task (also called a macrotask) is one unit of work such as running a script, a timer callback, or a click handler. A microtask is a small job that must run as soon as the current code finishes, such as a promise callback.
Each turn of the event loop does three things, in this order:
- Take one task from a task queue and run it to the end.
- Run every microtask in the microtask queue, including new microtasks that are added while this step runs.
- If it is time to draw a new frame (about every 16 ms on a 60 Hz screen), run
requestAnimationFramecallbacks, recalculate styles and layout, and paint the screen.
Then it starts again. This table shows where common jobs go:
| Queue | What goes there |
|---|---|
| Task queue | setTimeout and setInterval callbacks, event handlers (click, input, keydown), postMessage messages, the main script itself |
| Microtask queue | .then/.catch/.finally callbacks, the code after an await, queueMicrotask() callbacks, MutationObserver callbacks |
| Before the next paint | requestAnimationFrame callbacks |
Two facts follow from these steps. First, nothing can interrupt a running task, not even a click. Second, the screen is never painted in the middle of a task or while microtasks remain.
Predicting the order with await
The code after an await does not run immediately, even if the value is ready. JavaScript pauses the async function and queues the rest of it as a microtask. Read this example and write down your prediction before running it:
console.log("A: script starts");
setTimeout(() => console.log("H: timer task"), 0);
async function loadProfile() {
console.log("B: loadProfile starts");
await null; // pause here, even though null is ready at once
console.log("D: loadProfile resumes after await");
}
loadProfile();
queueMicrotask(() => console.log("E: queueMicrotask"));
Promise.resolve()
.then(() => console.log("F: first then"))
.then(() => console.log("G: second then"));
console.log("C: script ends");A: script starts B: loadProfile starts C: script ends D: loadProfile resumes after await E: queueMicrotask F: first then G: second then H: timer task
Walk through it:
- An async function runs synchronously until its first
await, so "B" prints straight after "A". await nullqueues "D" as a microtask. ThenqueueMicrotaskqueues "E" and the resolved promise queues "F". Microtasks run in the order they were queued.- "G" is queued only when "F" finishes, but it still runs in the same microtask step, because that step keeps going until the queue is empty.
- The timer callback is a task, so it waits for all of that. "H" is last.
queueMicrotask(fn) is the direct way to say "run this right after the current code, before anything else". Libraries use it to batch several changes into one update. In everyday code you rarely need it.
Microtasks can block the page too
Promises do not make work run in the background. A microtask runs on the same main thread as everything else, and the event loop does not paint or handle clicks until the microtask queue is empty. A chain of microtasks that keeps adding new ones holds the page just like a long loop:
const started = performance.now();
let count = 0;
setTimeout(() => {
console.log(`Timer finally ran after ${count} microtasks`);
}, 0);
function step() {
count++;
if (count < 100000) queueMicrotask(step); // queue the next step as a microtask
}
queueMicrotask(step);
console.log("Script finished, timer is waiting");Script finished, timer is waiting Timer finally ran after 100000 microtasks
The timer was ready almost at once, but it had to wait for all 100,000 microtasks. If such a chain never ends, the page freezes for good. This is why turning a slow loop into async code with await between steps does not stop it from blocking.
When does the screen update?
Because painting happens only between tasks, changing the DOM twice inside one task shows the user only the final state. A classic bug is a "Loading..." message that never appears:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>When the screen updates</title>
</head>
<body>
<button type="button" id="bad">Calculate (message never shows)</button>
<button type="button" id="good">Calculate (message shows)</button>
<p id="status">Ready.</p>
<script>
const status = document.querySelector("#status");
function heavyReport() {
const end = performance.now() + 1500; // pretend to work for 1.5 seconds
while (performance.now() < end) {}
return "Report for 1,240 orders is ready.";
}
document.querySelector("#bad").addEventListener("click", () => {
status.textContent = "Calculating, please wait..."; // never painted
status.textContent = heavyReport();
});
document.querySelector("#good").addEventListener("click", () => {
status.textContent = "Calculating, please wait...";
// Let the browser paint first, then start the heavy work in a new task
requestAnimationFrame(() => {
setTimeout(() => {
status.textContent = heavyReport();
}, 0);
});
});
</script>
</body>
</html>With the first button, the browser is busy from the first line to the last, so it never gets a chance to paint the waiting message. With the second button, requestAnimationFrame waits until the next frame is about to be painted, and the setTimeout inside it starts the heavy work in a new task after that paint. The message appears, then the report replaces it.
The real fix for a 1.5-second calculation is to make it smaller or move it to a Web Worker, but the paint-first pattern is useful for short jobs.
Long tasks and yielding
A long task is any task that keeps the main thread busy for more than 50 ms. During a long task, clicks and key presses wait, and the page feels sluggish. When you must do a lot of work on the main thread, split it into chunks and yield (give control back to the event loop) between them, so clicks and painting can happen in the gaps:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Yielding between chunks</title>
</head>
<body>
<button type="button" id="start">Check 300,000 bills</button>
<progress id="bar" max="100" value="0"></progress>
<p id="result"></p>
<p>Type here while it runs: <input></p>
<script>
// Give the event loop a chance to handle clicks and paint
function yieldToBrowser() {
if (globalThis.scheduler?.yield) return scheduler.yield();
return new Promise((resolve) => setTimeout(resolve, 0));
}
// Pretend each bill needs a small calculation to check it
function checkBill(amount) {
let x = amount;
for (let k = 0; k < 400; k++) x = (x * 31 + k) % 100003;
return x % 2 === 0;
}
const bills = Array.from({ length: 300000 }, (_, i) => (i % 97) * 13 + 99);
document.querySelector("#start").addEventListener("click", async () => {
const bar = document.querySelector("#bar");
let total = 0;
let flagged = 0;
let deadline = performance.now() + 10; // work for about 10 ms at a time
for (let i = 0; i < bills.length; i++) {
total += bills[i];
if (!checkBill(bills[i])) flagged++;
if (performance.now() > deadline) {
bar.value = (i / bills.length) * 100;
await yieldToBrowser();
deadline = performance.now() + 10;
}
}
bar.value = 100;
document.querySelector("#result").textContent =
`Total: ₹${total.toLocaleString("en-IN")}, bills flagged for review: ${flagged}`;
});
</script>
</body>
</html>The loop checks the clock and yields about every 10 ms. The progress bar moves and the input box keeps working during the job. scheduler.yield() is a newer browser API made for exactly this; it is not available in every browser yet, so the helper falls back to a setTimeout promise. Notice the fallback uses a task (setTimeout), not a microtask: yielding with await Promise.resolve() would not let the browser paint, as the previous section showed.
Node.js has two more queues
Node.js runs the same kind of event loop, with a few extras you will see in server code and older libraries:
process.nextTick(fn)queuesfnin a special queue that Node.js empties before the promise microtasks in CommonJS scripts.setImmediate(fn)runsfnin a later phase of the loop, after waiting for input and output. In the main script, the order ofsetTimeout(fn, 0)andsetImmediate(fn)is not guaranteed, so never rely on it.
// order.cjs (run with: node order.cjs)
setTimeout(() => console.log("timeout"), 0);
Promise.resolve().then(() => console.log("promise"));
process.nextTick(() => console.log("nextTick"));
console.log("sync");sync nextTick promise timeout
If you save the same code as an ES module (order.mjs), "promise" prints before "nextTick", because the module itself is run from inside a microtask. This is one more reason to write code that does not depend on fine details of queue order.
Common mistakes
- Thinking
asyncmeans "in the background". An async function runs on the main thread. Only Web Workers run JavaScript on another thread. - Updating a message and doing heavy work in the same task. The user never sees the message. Paint first, then work.
- Yielding with a microtask.
await Promise.resolve()inside a long loop does not let the browser paint or handle clicks. Yield withsetTimeoutorscheduler.yield(). - Relying on exact ordering between unrelated timers and promises. If one step must happen after another, chain them with
awaitinstead of guessing the queue order.
Key points
- Each turn of the event loop runs one task, then every microtask, then (when a frame is due)
requestAnimationFramecallbacks and painting. - Promise callbacks, code after
await, andqueueMicrotask()are microtasks; timers, events and messages are tasks. - An async function runs synchronously until its first
await; the rest is queued as a microtask. - The screen is only painted between tasks, so heavy work in one task hides earlier DOM changes and blocks clicks.
- Split long tasks into chunks of a few milliseconds and yield with
setTimeoutorscheduler.yield(). - Node.js adds
process.nextTickandsetImmediate; do not write code that depends on their exact order.
Exercise
Predict the output of this code, then run it to check. Explain why "3" appears where it does.
setTimeout(() => console.log("5"), 0);
async function run() {
console.log("1");
await undefined;
console.log("3");
}
run();
Promise.resolve().then(() => console.log("4"));
console.log("2");Show solution
1 2 3 4 5
run() prints "1" synchronously and pauses at await, which queues the rest of the function as a microtask. The .then callback is queued after it, so "3" runs before "4". Both microtasks run before the timer task "5".
Quick Quiz
1. After one task finishes, what does the event loop do before taking the next task?
The microtask step continues until the microtask queue is completely empty.
2. Why does a "Loading..." message set at the start of a long click handler never appear?
The DOM changes, but painting has to wait until the task ends, and by then the message has been replaced.
3. Which way of yielding lets the browser paint and handle clicks during a long loop?
A setTimeout callback is a new task, so the loop gives the event loop a chance to paint and handle input first.
4. When does the code written after await inside an async function run?
await always pauses the function and queues the rest as a microtask.
Learn JavaScript with a mentor
Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
