The previous lesson ended with an open question: you know what await does, but not why the output order is what it is. Why a top-level console.log appears after three asynchronous operations have been launched; why a .then on an already-fulfilled promise runs later than the code below it, but earlier than a setTimeout(f, 0) registered much sooner; why a heavy loop freezes the application even though the code is full of await. All of that is decided by machinery we have so far described with hand gestures: the event loop. In this lesson you are going to see it whole —the call stack, the environment APIs, the two queues and the algorithm that coordinates them—, you are going to predict the output of fragments that look impossible and trace them step by step, and you are going to understand exactly what freezes an interface and what does not. It is the piece that turns asynchrony from "it works by magic" into "it works like this".
Contents
- The five pieces
- Recap: the call stack
- The environment APIs: who really does the waiting
- The two queues: macrotasks and microtasks
- The event loop algorithm
- The golden rule of microtasks
- The classic exercise, solved with a full trace
- Where
awaitfits exactly - Blocking the thread: what freezes and what does not
- Why
awaitdoes not fix a heavy loop - Chunking the work and yielding the thread
requestAnimationFrameand nested timers- What all this means for Module 6
- Common Mistakes and Tips
- Exercises
- Conclusion
- The five pieces
The complete system has five components. Only one of them is "JavaScript"; the rest are provided by the environment —the browser or Node.
| Piece | What it is | Who provides it |
|---|---|---|
| Call stack | Where code runs; there is only one | The JavaScript engine |
| Environment APIs | Timers, network, events, files | The browser / Node |
| Macrotask queue | Ready callbacks from timers and events | The environment |
| Microtask queue | Promise callbacks and queueMicrotask |
The engine |
| Event loop | The coordinator that moves tasks onto the stack | The environment |
flowchart TD
subgraph Engine["JavaScript engine"]
P["Call stack<br/>(only one, LIFO)"]
MI["MICROtask queue<br/>.then · await · queueMicrotask"]
end
subgraph Env["Environment (browser / Node)"]
API["APIs: timers,<br/>network, events, files"]
MA["MACROtask queue<br/>setTimeout · setInterval · events"]
end
EL(["Event loop"])
P -->|"registers the operation"| API
API -->|"when it finishes, queues the callback"| MA
P -->|"promise settled"| MI
EL -->|"1 · is the stack empty?"| P
EL -->|"2 · drain ALL the microtasks"| MI
EL -->|"3 · take ONE macrotask"| MA
MI --> P
MA --> P
The idea to hold on to before going into detail: the stack is the only place where code runs, and the event loop is a doorman that only lets something new in when the stack is completely empty.
- Recap: the call stack
In 03-05 you learned that every function call creates an execution context that is pushed onto the stack, and that is popped when the function returns. It is a LIFO structure: last in, first out.
'use strict';
function effort(priority, hours) {
return priorityWeight(priority) * hours;
}
function priorityWeight(priority) {
return { high: 3, medium: 2, low: 1 }[priority] ?? 0;
}
function report() {
const total = effort('high', 12);
console.log(total); // 36
}
report();The stack evolves like this:
[report] ← report() is called [report, effort] ← report calls effort [report, effort, priorityWeight] ← effort calls priorityWeight [report, effort] ← priorityWeight returns 3 [report] ← effort returns 36 [report, console.log] ← it prints [] ← empty stack
That final state —empty stack— is the condition the event loop watches for. As long as there is anything on the stack, no asynchronous callback can start. And since there is only one stack, "something on the stack" means the thread is busy.
- The environment APIs: who really does the waiting
When you write setTimeout(f, 1000), that function is not part of JavaScript. The language, as defined by its specification, does not know how to measure time or make network requests. setTimeout is an environment API: the browser (or Node) provides it.
What happens, step by step:
- Your code calls
setTimeout(f, 1000). That call goes onto the stack. - The environment takes note: it stores
fand starts a timer outside the JavaScript thread. setTimeoutimmediately returns its identifier. It leaves the stack. Your code carries on.- A thousand milliseconds later, the timer comes due. The environment puts
fin the macrotask queue. Nothing runs yet. - When the event loop finds the stack empty, it takes
fout of the queue and puts it on the stack. Now it runs.
This explains once and for all the claim from 05-05 that "asynchrony does not make anything faster": the one doing the waiting is not your code, but another component. The timer is handled by the operating system; the network request is handled by the browser's HTTP subsystem, written in C++ and with its own threads. Your program merely signs up to be told.
The most common environment APIs:
| API | What it waits for | Which queue its callback goes into |
|---|---|---|
setTimeout / setInterval |
Time | Macrotasks |
| DOM events (click, keyboard) | User interaction | Macrotasks |
fetch (07-02) |
A network response | Microtasks (it is a promise) |
| File reads in Node | Disk | Macrotasks |
requestAnimationFrame |
The next repaint | Its own queue (section 12) |
- The two queues: macrotasks and microtasks
Here is the subtlety that explains almost every surprising ordering: there is not one queue, there are two, and they do not have the same priority.
| Macrotasks (tasks) | Microtasks | |
|---|---|---|
| What gets queued here | setTimeout, setInterval, DOM events, I/O |
.then/.catch/.finally, resumption after await, queueMicrotask |
| How many are processed per turn | One | All of them, until the queue is empty |
| Priority | Low | High |
| Can the queue grow while it is being processed? | Yes, but it waits for the next turn | Yes, and it is processed in the same batch |
| Is a repaint checked in between | Yes, between macrotasks | No, until the queue is empty |
The rule that follows from the second row is the most important one in the lesson:
Between two macrotasks, the engine drains the microtask queue completely. Every pending promise callback is dealt with before the next
setTimeoutruns.
This is demonstrated in four lines:
'use strict';
setTimeout(() => console.log('macrotask'), 0);
Promise.resolve().then(() => console.log('microtask'));
console.log('synchronous');
// synchronous
// microtask ← even though it was registered AFTER the setTimeout
// macrotaskEven though the setTimeout was written first and its delay is zero, the microtask is dealt with first. This is not an implementation detail of one particular browser: it is in the specification and behaves the same everywhere.
- The event loop algorithm
The event loop is, literally, an infinite loop that repeats these steps:
while (the program is still alive) {
1. Is there anything on the call stack?
Yes → wait. Nothing is touched.
No → carry on.
2. Drain the ENTIRE microtask queue:
while (there are microtasks) {
take the first one and run it to completion
(if it generates new microtasks, they are added to this same queue
and are also processed now)
}
3. [In the browser] If a repaint is due, repaint now.
4. Take ONE macrotask from the queue and run it to completion.
5. Go back to step 2.
}Four practical consequences can be read straight off that algorithm:
- Synchronous code always wins. Step 1 waits for the stack to be empty, and the stack does not empty until the whole top-level script has finished.
- Microtasks go before macrotasks, always, regardless of the order they were registered in (steps 2 and 4).
- A macrotask runs to completion before the next one is dealt with. There are no interruptions halfway through a function.
- The repaint happens between macrotasks, never halfway through one. That is what makes a long loop freeze the screen.
- The golden rule of microtasks
Step 2 has a dangerous consequence: if a microtask queues another microtask, the new one is processed in the same batch, not on the next turn. An infinite chain of microtasks hangs the program forever, and the setTimeout never gets to run.
'use strict';
// ⚠ Do NOT run this: it freezes the tab
function microtaskLoop() {
Promise.resolve().then(microtaskLoop); // each one queues the next
}
setTimeout(() => console.log('I never get here'), 0);
microtaskLoop();Compare it with the equivalent version made of macrotasks, which hangs nothing:
'use strict';
let cycles = 0;
function macrotaskLoop() {
cycles += 1;
if (cycles < 1000) setTimeout(macrotaskLoop, 0);
}
setTimeout(() => console.log('I do get here'), 0);
macrotaskLoop();
// I do get here ← because each turn yieldsThe difference is exactly step 4 of the algorithm: one macrotask per turn, so the others get their chance. Microtasks do not yield.
There is also an explicit way of queueing a microtask, with no promise involved:
It is rarely used in application code, but it is useful for understanding the model: it is the exact equivalent of Promise.resolve().then(...), without creating a promise.
| You want to… | Use |
|---|---|
| Run something after the current code, before repainting | queueMicrotask or Promise.resolve().then |
| Yield the thread so the interface can breathe | setTimeout(f, 0) |
| Run just before the next repaint | requestAnimationFrame (section 12) |
- The classic exercise, solved with a full trace
This fragment is the standard event-loop exam. Read it, bet on an order and then follow the trace.
'use strict';
console.log('1 · script');
setTimeout(() => console.log('2 · timeout A'), 0);
Promise.resolve()
.then(() => console.log('3 · then A'))
.then(() => console.log('4 · then B'));
async function task() {
console.log('5 · inside task, before the await');
await null;
console.log('6 · inside task, after the await');
}
task();
setTimeout(() => console.log('7 · timeout B'), 0);
queueMicrotask(() => console.log('8 · explicit microtask'));
console.log('9 · end of script');Output:
1 · script 5 · inside task, before the await 9 · end of script 3 · then A 6 · inside task, after the await 8 · explicit microtask 4 · then B 2 · timeout A 7 · timeout B
And now the trace, moment by moment.
Synchronous phase (the stack holds the script; the event loop does not intervene):
| Line | What happens | Queues afterwards |
|---|---|---|
console.log('1') |
Prints 1 | — |
setTimeout(…A…, 0) |
The environment starts the timer | — |
Promise.resolve().then(…) |
The promise is already fulfilled: it queues then A |
micro: [then A] |
.then(…B…) |
It is registered on a pending promise (the one the first .then returns). Nothing is queued yet |
micro: [then A] |
task() |
Goes onto the stack, prints 5, reaches the await |
micro: [then A] |
await null |
null is wrapped in a fulfilled promise → queues the resumption of task. The function suspends and returns to the script |
micro: [then A, resume task] |
setTimeout(…B…, 0) |
Another timer started | — |
queueMicrotask(…) |
Queues directly | micro: [then A, resume task, explicit micro] |
console.log('9') |
Prints 9 | — |
| End of script | The stack empties. The event loop steps in | macro: [timeout A, timeout B] |
Look at the row for the second .then: nothing was queued. A .then only queues its callback when the promise it is registered on settles, and the promise the first .then returns stays pending until that first one runs. That detail is what puts 4 · then B behind 8.
Step 2 of the algorithm: drain the microtasks.
| Taken out | Prints | Effect on the queue |
|---|---|---|
then A |
3 | Its promise fulfils → queues then B. Queue: [resume task, explicit micro, then B] |
resume task |
6 | The task function carries on after the await and finishes |
explicit micro |
8 | — |
then B |
4 | Queue empty → we leave step 2 |
Here you see the golden rule in action: then B was added during the draining and was still processed in the same batch, before any macrotask was touched.
Step 4: one macrotask. timeout A runs → prints 2. Back to step 2: there are no microtasks. Next macrotask: timeout B → prints 7.
flowchart TD
S["SYNCHRONOUS PHASE<br/>1 · 5 · 9"] --> M1["MICROTASKS (all)<br/>3 → 6 → 8 → 4"]
M1 --> R["repaint (if due)"]
R --> T1["MACROTASK 1<br/>2 · timeout A"]
T1 --> M2["microtasks (none)"]
M2 --> T2["MACROTASK 2<br/>7 · timeout B"]
If you got the whole order right first time, you have understood the model. If not, the part that usually trips people up is 4 · then B: remember that chained .thens are not all queued at once, but one after another as their promises fulfil.
- Where
await fits exactly
await fits exactlyYou now have the missing piece from 05-06. await does three things:
- Evaluates the expression in front of it and, if it is not a promise, wraps it in a fulfilled one.
- Suspends the function and returns control to whoever called it. The stack unwinds down to there.
- Registers the resumption as a microtask, which will run when the promise settles and its turn comes.
Two important conclusions follow.
The body of an async function is synchronous up to the first await. This surprises a lot of people:
'use strict';
async function load() {
console.log('A · this is synchronous');
const tasks = await readBacklog();
console.log('C · this is a microtask');
return tasks;
}
load();
console.log('B · this comes after A');
// A · this is synchronous
// B · this comes after A
// C · this is a microtask (when the promise settles)Calling an async function does not defer its start: it runs immediately until it meets an await. It is useful to know: if you want an async function to validate its arguments and fail fast, put the validations before the first await and they will throw at the very instant of the call… well, almost: since the function returns a promise, the throw turns into a rejection. But the work is done right away.
Every await costs at least one round of microtasks. Even if the promise is already fulfilled:
async function three() {
console.log('1');
await null; // microtask
console.log('2');
await null; // another microtask
console.log('3');
}
three();
Promise.resolve().then(() => console.log('X'));
Promise.resolve().then(() => console.log('Y'));
// 1
// X ← they interleave: every await yields the turn
// 2
// Y
// 3That interleaving is the visual proof that await is not a magic pause: it is a return followed by a resumption queued as a microtask. And it explains why putting fifty unnecessary awaits in a function has a real cost, however small.
- Blocking the thread: what freezes and what does not
Now the problem that opened 05-05 can be explained precisely.
'use strict';
console.log('Calculation starts');
let sum = 0;
for (let i = 0; i < 2_000_000_000; i++) {
sum += i; // several seconds occupying the stack
}
console.log('Calculation ends', sum);While that loop runs, the stack does not empty. And if the stack does not empty:
- step 1 of the algorithm never passes;
- no microtask is processed;
- no macrotask is processed;
- there is no repaint (step 3), so the screen stays frozen exactly as it was;
- the user's clicks queue up as macrotasks and will all run at once when it finishes, with the corresponding bewilderment.
That is the state the browser eventually reports as "the page is not responding".
It is worth being clear about what blocks and what does not:
| Operation | Does it block the thread? | Why |
|---|---|---|
| A loop of a billion iterations | Yes | It occupies the stack the whole time |
JSON.parse of a 50 MB file |
Yes | It is synchronous code, however fast it is |
| Sorting an array of a million elements | Yes | sort is synchronous |
await readBacklog() |
No | It suspends the function and frees the stack |
setTimeout(f, 5000) |
No | The timer is handled by the environment |
alert('hello') |
Yes, and brutally so | It is synchronous and blocking by design |
The rule sums up in one sentence: what blocks is not waiting, it is calculating. A well-made wait frees the thread; a long calculation hijacks it, whatever syntax surrounds it.
- Why
await does not fix a heavy loop
await does not fix a heavy loopA very widespread mistake is thinking that wrapping the calculation in an async function makes it non-blocking.
'use strict';
// ✗ It still freezes exactly the same
async function calculateTotalEffort(tasks) {
let total = 0;
for (let i = 0; i < 2_000_000_000; i++) {
total += i;
}
return total;
}
console.log('before');
calculateTotalEffort([]); // the interface freezes just the same
console.log('after'); // comes out several seconds laterYou have known the reason since section 8: the body of an async function is synchronous up to the first await, and here there is none. async does not create a thread or move anything anywhere else; it only changes what the function returns.
And sticking in an await that waits for nothing does not help either:
async function doesNotFixItEither(tasks) {
await null; // yields the thread ONCE, at the beginning
for (let i = 0; i < 2_000_000_000; i++) { /* … */ } // and then hijacks it all the same
}After that microtask, the loop occupies the stack again for seconds. The only real way of not blocking with a heavy calculation is one of these two:
- Chunk it and yield the thread between chunks (section 11).
- Move it off the main thread with a Web Worker, which really does run JavaScript on a separate thread with its own event loop. It is the correct solution for genuinely intensive work, and it is studied in 09-02.
- Chunking the work and yielding the thread
Chunking means processing one batch, handing control back to the event loop and scheduling the next batch as a macrotask. Applied to a gigantic Taller Nómada backlog:
'use strict';
/**
* Processes an array in batches without blocking the thread.
* @param {Array} items
* @param {Function} action what to do with each item
* @param {number} batchSize how many per turn
* @returns {Promise<void>}
*/
function inBatches(items, action, batchSize = 500) {
return new Promise((resolve) => {
let index = 0;
function nextBatch() {
const end = Math.min(index + batchSize, items.length);
for (; index < end; index++) {
action(items[index], index);
}
if (index < items.length) {
setTimeout(nextBatch, 0); // ← yields the thread: one turn of the loop gets in
} else {
resolve();
}
}
nextBatch();
});
}
// Use with a simulated backlog of 200,000 tasks
const huge = Array.from({ length: 200_000 }, (_, i) => ({ id: i + 1, estimatedHours: (i % 40) + 1 }));
let hours = 0;
await inBatches(huge, (t) => { hours += t.estimatedHours; }, 1000);
console.log(`Total: ${hours} h`);Between one batch and the next, the event loop completes a turn: it processes microtasks, repaints and handles the user's clicks. The application stays alive. The price is that the whole process takes a bit longer —each nested setTimeout has its minimum of about 4 ms— and there is the trade-off: the batch size decides the balance between smoothness and total speed.
A modern alternative, when the goal is to let the interface breathe:
// Only in browsers that support it
await scheduler.yield(); // "give up the turn and resume as soon as you can"And an important warning: do not use await on an already-fulfilled promise to yield the thread. Since it is a microtask, it is processed in the same turn, with no repaint and no attention paid to events. To yield for real you need a macrotask (setTimeout) or a specific API.
| Technique | Does it really yield the thread? |
|---|---|
await null / await Promise.resolve() |
No: microtask |
await new Promise((r) => setTimeout(r, 0)) |
Yes: macrotask |
setTimeout(next, 0) |
Yes |
| Web Worker | Yes, and it uses another core as well (09-02) |
requestAnimationFrame and nested timers
requestAnimationFrame and nested timersTwo brief notes to complete the map, which you will pick up again in Module 6 and in Module 9.
requestAnimationFrame(callback) schedules a function to run just before the next repaint, synchronized with the screen's refresh rate (about 60 times a second on a normal monitor). It is neither a macrotask nor a microtask: it has its own moment in the algorithm, step 3.
function animate(timestamp) {
// …update positions…
requestAnimationFrame(animate); // reschedules itself for the next frame
}
requestAnimationFrame(animate);The difference from setTimeout(f, 16) is that rAF is synchronized with the repaint: it does not run when the tab is hidden —which saves battery— and it never produces two updates between two frames. For any animation, rAF is the right choice; setTimeout produces stutter.
Nested timers. We already noted it in 05-05: by specification, from the fifth nested setTimeout onwards browsers raise the minimum to about 4 ms. So a setTimeout(f, 0) that reschedules itself does not go round a thousand times a second, but about two hundred and fifty. It is a deliberate limitation to stop a timer loop from burning the processor, and it is the reason chunking with setTimeout has a cost that has to be measured.
- What all this means for Module 6
In the next lesson you close Module 5, and in Module 6 you start building the Nómada Tasks interface. Everything you have just learned becomes very concrete there. Note down these five consequences:
-
Event handlers are macrotasks. Every click on a "Mark as done" button queues a macrotask. If your handler takes 300 ms, the user perceives the interface as sticky, because nothing is repainted while it lasts.
-
Repainting only happens between macrotasks. If in a single handler you change an element's text ten times, the user will see only the last value: there is no repaint halfway through a function. That is good news —it avoids flickering— and it explains why changes get batched naturally.
-
A heavy calculation in a handler freezes the entire application, with the task list half-painted and the buttons unresponsive. Expensive logic gets chunked, moved to a worker or done outside the handler.
-
The order between
awaitand events matters. If a click handler doesawait saveTask(), the user can press the button again during the wait and queue a second handler. Disabling the button while the operation is under way is not decoration: it is correctness. -
<script type="module">has an implicitdefer(05-04), so your code runs with the HTML already built, and that execution is one more macrotask in the page's life cycle.
With that, the interface's behavior stops being mysterious: it is the algorithm from section 5, applied to user events.
Common Mistakes and Tips
- Believing that
setTimeout(f, 0)runs right now. It only queues. The delay is a minimum, and on top of that the stack has to empty. - Believing that
asyncmakes something non-blocking.asynconly changes what the function returns. What blocks is calculating, not the syntax. - Using
await Promise.resolve()to "let the interface breathe". It is a microtask: it is processed in the same turn, with no repaint. You need a macrotask. - Infinite chains of microtasks. A microtask that queues another one with no stopping condition hangs the program irrecoverably and with no error message.
- Trusting the exact timing of a
setInterval. If the thread is busy when it comes due, the callback is delayed; and if it is delayed a lot, browsers merge repetitions. To measure real time, use marks withDate.now()orperformance.now(). - Assuming a fixed order between callbacks of different kinds. Between two
setTimeouts with the same delay, registration order rules; between a macrotask and a microtask, the micro always wins. But do not assume anything beyond that. - Debugging the event loop with
console.logexpecting to see the stack. The DevTools Performance panel draws the stack, the tasks and the repaints on a timeline: that is the right tool, and it is studied in 08-01 and 09-01. - Tip: when an asynchronous ordering surprises you, write it out like the trace in section 7: a table with the microtask queue and the macrotask queue after each line. Any doubt is settled in five minutes.
Exercises
Exercise 1 — Predict and trace. State the exact output of this fragment and build the trace table (the state of the two queues after each synchronous line).
console.log('start');
setTimeout(() => {
console.log('T1');
Promise.resolve().then(() => console.log('T1-micro'));
}, 0);
setTimeout(() => console.log('T2'), 0);
Promise.resolve().then(() => {
console.log('P1');
setTimeout(() => console.log('P1-timeout'), 0);
});
(async () => {
console.log('async before');
await Promise.resolve();
console.log('async after');
})();
console.log('end');Exercise 2 — Measure the blocking. Write two versions of a function that sums the weighted effort of an array of 300,000 simulated tasks: one synchronous and one chunked with inBatches. Measure with Date.now() how long each takes and, above all, check with a setInterval that prints a dot every 50 ms which of the two lets that interval keep working during the calculation.
Exercise 3 — Diagnosis. This code is meant to show a notice while it loads and hide it when it finishes, but the notice "is never seen". Explain why using the event loop algorithm and propose the fix.
function loadWithNotice() {
showNotice('Loading…'); // changes the state that will be painted
const result = heavySyncCalculation(); // 3 seconds
hideNotice();
return result;
}Solutions
Exercise 1
Output:
Trace of the synchronous phase:
| Line executed | Prints | Microtask queue | Macrotask queue |
|---|---|---|---|
console.log('start') |
start | — | — |
setTimeout(T1, 0) |
— | — | [T1] |
setTimeout(T2, 0) |
— | — | [T1, T2] |
Promise.resolve().then(P1) |
— | [P1] | [T1, T2] |
async IIFE up to the await |
async before | [P1, resume-async] | [T1, T2] |
console.log('end') |
end | [P1, resume-async] | [T1, T2] |
Stack empty → step 2, drain the microtasks: P1 prints P1 and queues P1-timeout in the macrotasks (leaving [T1, T2, P1-timeout]); resume-async prints async after. Microtask queue empty.
Step 4, one macrotask: T1 prints T1 and queues T1-micro in the microtasks. Back to step 2: the queue is drained → T1-micro. Next macrotask: T2. After that: P1-timeout.
The fine point of the exercise is that T1-micro comes out immediately after T1, before T2, even though T2 had been waiting in its queue from the start: at the end of every macrotask, all pending microtasks are drained.
Exercise 2
'use strict';
const WEIGHTS = { high: 3, medium: 2, low: 1 };
const PRIORITIES = ['high', 'medium', 'low'];
const huge = Array.from({ length: 300_000 }, (_, i) => ({
id: i + 1,
priority: PRIORITIES[i % 3],
estimatedHours: (i % 40) + 1
}));
// ── Synchronous version ───────────────────────────────────────────
function syncEffort(tasks) {
let total = 0;
for (const t of tasks) total += WEIGHTS[t.priority] * t.estimatedHours;
return total;
}
// ── Chunked version ───────────────────────────────────────────────
function batchedEffort(tasks, batchSize = 5000) {
return new Promise((resolve) => {
let index = 0;
let total = 0;
function batch() {
const end = Math.min(index + batchSize, tasks.length);
for (; index < end; index++) {
total += WEIGHTS[tasks[index].priority] * tasks[index].estimatedHours;
}
if (index < tasks.length) setTimeout(batch, 0);
else resolve(total);
}
batch();
});
}
// ── The "heartbeat" that reveals whether the thread is free ───────
let beats = 0;
const pulse = setInterval(() => { beats += 1; }, 50);
let t0 = Date.now();
const a = syncEffort(huge);
const syncTime = Date.now() - t0;
const beatsDuringSync = beats;
beats = 0;
t0 = Date.now();
const b = await batchedEffort(huge);
const batchedTime = Date.now() - t0;
const beatsDuringBatched = beats;
clearInterval(pulse);
console.log(`Synchronous: ${a} in ${syncTime} ms · beats: ${beatsDuringSync}`);
console.log(`Chunked: ${b} in ${batchedTime} ms · beats: ${beatsDuringBatched}`);
// Synchronous: 82000000 in 12 ms · beats: 0
// Chunked: 82000000 in 320 ms · beats: 6The exact numbers vary from machine to machine, but the pattern is always the same and that is what you should read:
- The synchronous version is faster overall —it does not pay the cost of the
setTimeouts— but it records zero beats: during the whole calculation, the interval did not run once. The thread was hijacked, and in a real application that means a frozen screen. - The chunked version takes considerably longer but allows several beats: between one batch and the next, the event loop completed whole turns, servicing timers, repainting and responding to clicks.
That is the engineering decision in its purest form: total time is sacrificed so that the application stays alive. If the calculation comfortably fits in a few milliseconds, do not chunk it; if it is going to last more than about 50 ms, chunk it or move it to a Web Worker.
Exercise 3
The notice is never seen because the repaint happens at step 3 of the algorithm, between macrotasks, and here the stack never empties. The real sequence is:
showNotice('Loading…')changes the page's internal state… but draws nothing: it only marks that a repaint is due.heavySyncCalculation()occupies the stack for three seconds. The event loop never reaches step 3, so there is no repaint.hideNotice()undoes the change, still without the stack having emptied.- The function returns, the stack empties and at last a repaint happens… showing the final state, in which the notice is already hidden.
The user sees three seconds of freezing and no notice at all. The fix consists of letting a repaint happen between showing the notice and starting the calculation, yielding the thread with a macrotask:
const yieldThread = () => new Promise((resolve) => setTimeout(resolve, 0));
async function loadWithNotice() {
showNotice('Loading…');
await yieldThread(); // ✓ the loop completes a turn and REPAINTS
try {
return await batchedEffort(data); // ✓ and it does not freeze while calculating either
} finally {
hideNotice(); // runs whatever happens (02-05)
}
}Two details worth underlining. The await yieldThread() has to be a macrotask: if you wrote await Promise.resolve() it would be a microtask, it would be processed in the same turn and there would be no repaint, leaving the problem exactly as it was. And chunking the calculation with batchedEffort solves the second half of the problem: without it, the notice would be seen, but the interface would still be frozen for the three seconds.
Conclusion
There is no magic left in JavaScript's asynchrony. The system has five pieces: a single call stack where all the code runs; the environment APIs that are the ones actually doing the waiting —the timer is handled by the operating system, the network by the browser—; a macrotask queue for timer and event callbacks; a microtask queue for promises, awaits and queueMicrotask; and the event loop, a doorman that only lets something new in when the stack is completely empty.
Its algorithm fits in four steps, and everything else follows from them: wait for the stack to empty, drain the entire microtask queue, repaint if due, and take one single macrotask before starting over. From that come the three operational rules you should have engraved: synchronous code always wins; all the microtasks are dealt with before the next macrotask, regardless of the order they were registered in; and an infinite chain of microtasks hangs the program while one of macrotasks does not, because those yield the turn on every round. You have traced the classic exercise line by line and seen why then B ends up behind the explicit microtask —chained .thens are not queued all at once, but as their promises fulfil— and you know exactly what await does: it evaluates, suspends the function by handing control back, and queues the resumption as a microtask, so that the body of an async function is synchronous up to the first await and every await costs at least one round.
And you have the right diagnosis for blocking: what freezes is not waiting, it is calculating. An await frees the stack; a loop of two billion iterations hijacks it, and with it go the microtasks, the macrotasks, the user's clicks and —most visibly— the repaint. That is why wrapping the calculation in an async function fixes nothing, and why await Promise.resolve() does not really yield the thread either: it is a microtask. There are two real ways out: chunk the work, yielding with a macrotask between batches and consciously accepting that it takes longer in exchange for the application staying alive, or move it off the main thread with a Web Worker (09-02). You also know that requestAnimationFrame has its own moment, right before the repaint, and that nested timers have a minimum of about 4 ms.
With this you close the asynchronous part of the module, and you have noted down the five consequences waiting for you in Module 6: event handlers are macrotasks, repainting only happens between them, a heavy calculation inside a handler freezes the whole interface, and a button that triggers an asynchronous operation has to be disabled while it lasts. One last piece of the language remains to be discovered, and it is the one that explains how something you have been using since Module 2 works underneath without your ever asking: what exactly for...of does when it walks an array, a Map or a Set, how a Board can be made walkable with that same syntax, and how sequences are generated that are computed only when they are asked for —including infinite and asynchronous ones. It is the subject of Iterators and Generators, the last lesson of the module.
JavaScript Course: From Beginner to Advanced
Module 1: Introduction to JavaScript
- What Is JavaScript?
- Setting Up Your Development Environment
- Your First JavaScript Program
- JavaScript Syntax and Basic Concepts
- Variables and Data Types
- Basic Operators
- Type Conversion and Comparisons
- The Course Project: Nómada Tasks
Module 2: Control Structures
- Conditional Statements
- Loops: for, while, do-while
- Switch Statements
- Flow Control: break, continue and Nested Loops
- Error Handling with try-catch
Module 3: Functions
- Defining and Calling Functions
- Function Expressions and Arrow Functions
- Parameters and Return Values
- Scope and Closures
- Hoisting and the Execution Context
- Higher-Order Functions
- Recursion
Module 4: Objects and Arrays
- Introduction to Objects
- Object Methods and the
thisKeyword - Arrays: Basics and Methods
- Iterating over Arrays
- Searching, Sorting and Aggregating Data: find, sort and reduce
- Array Destructuring
- Object Destructuring, Spread and Rest
- JSON and Copying Objects
Module 5: Advanced Objects and Functions
- Prototypes and Inheritance
- Classes and Object-Oriented Programming
- Encapsulation: Getters, Setters and Private Fields
- Modules: Import and Export
- Asynchronous JavaScript: Callbacks
- Promises and Async/Await
- The Event Loop and the Microtask Queue
- Iterators and Generators
Module 6: The Document Object Model (DOM)
- Introduction to the DOM
- Selecting and Manipulating DOM Elements
- Handling Events
- Propagation, Delegation and Custom Events
- Creating and Removing DOM Elements
- Rendering Lists and HTML Templates
- Handling and Validating Forms
Module 7: Browser APIs and Advanced Topics
- Local and Session Storage
- The Fetch API and AJAX
- Robust Requests: Errors, Timeouts and AbortController
- WebSockets
- Service Workers and Progressive Web Apps (PWAs)
- Essential Browser APIs
- Introduction to WebAssembly
Module 8: Testing and Debugging
- Debugging JavaScript
- Code Quality: ESLint, Prettier and Conventions
- Unit Testing with Jest
- Test Doubles: Mocks, Stubs and Spies
- Integration Testing
- End-to-End Testing with Cypress
Module 9: Performance and Optimization
- Measure Before You Optimize: DevTools and Web Vitals
- Optimizing JavaScript Performance
- Memory Management
- Efficient DOM Manipulation
- Lazy Loading and Code Splitting
Module 10: JavaScript Frameworks and Libraries
- Why Frameworks Exist
- Introduction to React
- State Management with Redux
- Vue.js Basics
- Angular Basics
- Choosing the Right Framework
