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

  1. The five pieces
  2. Recap: the call stack
  3. The environment APIs: who really does the waiting
  4. The two queues: macrotasks and microtasks
  5. The event loop algorithm
  6. The golden rule of microtasks
  7. The classic exercise, solved with a full trace
  8. Where await fits exactly
  9. Blocking the thread: what freezes and what does not
  10. Why await does not fix a heavy loop
  11. Chunking the work and yielding the thread
  12. requestAnimationFrame and nested timers
  13. What all this means for Module 6
  14. Common Mistakes and Tips
  15. Exercises
  16. Conclusion

  1. 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.

  1. 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.

  1. 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:

  1. Your code calls setTimeout(f, 1000). That call goes onto the stack.
  2. The environment takes note: it stores f and starts a timer outside the JavaScript thread.
  3. setTimeout immediately returns its identifier. It leaves the stack. Your code carries on.
  4. A thousand milliseconds later, the timer comes due. The environment puts f in the macrotask queue. Nothing runs yet.
  5. When the event loop finds the stack empty, it takes f out 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)

  1. 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 setTimeout runs.

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
// macrotask

Even 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.

  1. 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.

  1. 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 yields

The 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:

queueMicrotask(() => console.log('explicit microtask'));

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)

  1. 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.

  1. Where await fits exactly

You now have the missing piece from 05-06. await does three things:

  1. Evaluates the expression in front of it and, if it is not a promise, wraps it in a fulfilled one.
  2. Suspends the function and returns control to whoever called it. The stack unwinds down to there.
  3. 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
// 3

That 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.

  1. 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.

  1. Why await does not fix a heavy loop

A 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 later

You 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:

  1. Chunk it and yield the thread between chunks (section 11).
  2. 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.

  1. 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)

  1. requestAnimationFrame and nested timers

Two 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.

  1. 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:

  1. 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.

  2. 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.

  3. 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.

  4. The order between await and events matters. If a click handler does await 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.

  5. <script type="module"> has an implicit defer (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 async makes something non-blocking. async only 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 with Date.now() or performance.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.log expecting 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:

start
async before
end
P1
async after
T1
T1-micro
T2
P1-timeout

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: 6

The 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:

  1. showNotice('Loading…') changes the page's internal state… but draws nothing: it only marks that a repaint is due.
  2. heavySyncCalculation() occupies the stack for three seconds. The event loop never reaches step 3, so there is no repaint.
  3. hideNotice() undoes the change, still without the stack having emptied.
  4. 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

Module 2: Control Structures

Module 3: Functions

Module 4: Objects and Arrays

Module 5: Advanced Objects and Functions

Module 6: The Document Object Model (DOM)

Module 7: Browser APIs and Advanced Topics

Module 8: Testing and Debugging

Module 9: Performance and Optimization

Module 10: JavaScript Frameworks and Libraries

Module 11: Final Project

© Copyright 2026. All rights reserved