A2 · Part 3 · Advanced
Advanced async
Past promises: pausable functions, streams, and running real work concurrently.
11On this page
- Microtask starvation
- Node's event loop has phases; the browser's doesn't
- Generators — functions that pause
- Generators can be told to stop or fail — .return() and .throw()
- Iterator helpers — array methods, without building an array first
- Array.fromAsync — collecting an async iterable into a real array
- Async generators and for await...of
- Streams and backpressure
- TransformStream — reshaping a stream mid-pipeline
- Web Workers, SharedArrayBuffer, Atomics
- Concurrency control — running a lot of things, but not all at once
Microtask starvation
The microtask queue always drains completely before the event loop touches a macrotask — which is usually the right behavior, until a microtask keeps scheduling another microtask. Nothing else — not a timer, not a render, not user input — ever gets a turn.
function loopForever() {
queueMicrotask(loopForever); // each run schedules the next one before yielding
}
loopForever();
// the page is now permanently frozen — every macrotask queued after this
// point (clicks, timers, even rendering) waits behind an infinite microtask queue
queueMicrotask(fn) schedules fn directly on
that same microtask queue a resolved promise's .then()
uses — the explicit version of the implicit scheduling promises do.
Node has an even higher-priority version,
process.nextTick(fn), which drains completely before
even the microtask queue gets its turn — Node-only, and easy
to starve yourself with the same recursive pattern
above.
Node's event loop has phases; the browser's doesn't
Browser-side, "macrotask" is one flat queue. Node's libuv
event loop is a fixed cycle of named phases, each with its own queue,
run in order every tick: timers (due
setTimeout/setInterval callbacks) →
pending callbacks → poll (I/O — the bulk of real work)
→ check (setImmediate) → close callbacks,
then back to the top. setImmediate is Node's own
addition — no equivalent in the browser at all — meaning "run after
I/O this cycle, before the next timers phase," a more precise
guarantee than setTimeout(fn, 0) gives you.
Generators — functions that pause
A function* doesn't run to completion when called — it
returns an iterator, and each .next() runs the body only
until the next yield, then pauses with everything
(local variables included) intact until .next() is
called again.
function* range(start, end) {
for (let i = start; i < end; i++) yield i;
}
console.log([...range(1, 5)]); // what happens?
function* outer() {
yield 1;
yield* [2, 3]; // yield* delegates to another iterable, one value at a time
yield* innerGen();
}
function* innerGen() {
yield 4;
yield 5;
}
console.log([...outer()]); // what happens?
[1, 2, 3, 4], then [1, 2, 3, 4, 5]. Spread
works on any generator because a generator's return value is
an iterator (it implements Symbol.iterator, covered
properly next chapter). yield* is what makes generators
composable — one generator can hand off to another without unpacking
it into an array first.
The genuinely two-way part: .next(value) doesn't just
resume the generator, it becomes the result of the
yield expression that paused it.
function* runningTotal() {
let total = 0;
while (true) {
const n = yield total; // pauses here, returning "total" — resumes with whatever .next(n) sends
total += n;
}
}
const calc = runningTotal();
console.log(calc.next().value); // what happens? (no value to send yet — this call just starts it)
console.log(calc.next(5).value); // what happens?
console.log(calc.next(10).value); // what happens?
0, then 5, then 15. The first
.next() has nothing to send into — there's no
paused yield waiting for a value yet, it just runs the
generator up to its first yield total and returns that
0. Every call after that both resumes execution
and delivers a value into the paused expression — genuine
two-way communication, not just "give me the next thing."
Generators can be told to stop or fail — .return() and .throw()
function* worker() {
try {
yield 1;
yield 2;
yield 3;
} finally {
console.log("cleanup ran"); // runs no matter HOW the generator stops
}
}
const g = worker();
console.log(g.next()); // { value: 1, done: false }
console.log(g.return(99)); // what happens?
{ value: 99, done: true } — but only after
"cleanup ran" logs first. .return(value)
acts as if a return value statement executed right at the
paused yield, which means any finally block
wrapped around it still runs. .throw(error) is the
matching move for failure: it makes the paused yield
behave as if it had thrown, letting a try/catch
inside the generator handle it, or propagating out if there
isn't one. for...of calls .return()
automatically on a break, so cleanup runs there too,
without anyone writing it explicitly.
Iterator helpers — array methods, without building an array first
function* naturals() {
let n = 1;
while (true) yield n++; // an infinite sequence — could never become a real array
}
const firstFiveSquares = naturals()
.map((n) => n * n)
.filter((n) => n % 2 === 0)
.take(5);
console.log([...firstFiveSquares]); // what happens?
[4, 16, 36, 64, 100] — .map,
.filter, .take, .drop and a
handful more now exist directly on any iterator, lazily: nothing runs
until something actually consumes the result (here, the spread), and
each value flows through the whole chain one at a time instead of
building an intermediate array at every step. That laziness is the
whole point — the infinite naturals() generator above
would never finish if .map() ran eagerly across it first,
but .take(5) only ever pulls five values through the
chain before stopping. Unlike Temporal (covered in
Regex & dates), iterator
helpers landed across every major engine well before this site's own
timeline — safe to reach for directly, no feature check needed.
Array.fromAsync — collecting an async iterable into a real array
async function* pageThrough() {
yield 1;
yield 2;
yield 3;
}
const all = await Array.fromAsync(pageThrough());
console.log(all); // [1, 2, 3] — a real array, not an async generator anymore
Before this existed, collecting an async generator's output meant a
manual for await...of loop pushing into an array by
hand. Array.fromAsync is that loop, built in — useful
once you've decided you do want everything in memory at once
(a small, known-bounded result set), rather than streaming it the way
the paging example below does. Same as the iterator helpers above:
broadly supported today, nothing to feature-detect first.
Async generators and for await...of
async function* pageThrough(url) {
let next = url;
while (next) {
const page = await fetch(next).then((r) => r.json());
yield page.items;
next = page.nextUrl;
}
}
for await (const items of pageThrough("/api/items")) {
render(items); // runs once per page, as each one arrives — never holds every page in memory at once
}
async function* combines both ideas at once — every
.next() now returns a promise of the next
value, so the consumer can await each item as it's
produced instead of needing everything ready up front.
for await...of is the loop built to consume exactly
that: pause for each value's promise, unwrap it, run the body,
repeat.
Streams and backpressure
const response = await fetch("/api/large-file");
const reader = response.body.getReader(); // a ReadableStream, read chunk by chunk
while (true) {
const { done, value } = await reader.read(); // value is one chunk (a Uint8Array), not the whole file
if (done) break;
processChunk(value);
}
The point of a stream is never holding the whole thing in memory — a multi-gigabyte download processed chunk by chunk costs roughly one chunk's worth of memory, not the whole file's. Backpressure is what keeps a fast producer from burying a slow consumer in memory: a well-built stream only pulls the next chunk once the consumer signals it's ready for one, rather than the producer blasting data in as fast as it can regardless of whether anything downstream can keep up.
TransformStream — reshaping a stream mid-pipeline
const upperCaseStream = new TransformStream({
transform(chunk, controller) {
controller.enqueue(chunk.toUpperCase());
},
});
const response = await fetch("/api/text-stream");
const upper = response.body
.pipeThrough(new TextDecoderStream()) // bytes -> text
.pipeThrough(upperCaseStream); // text -> upper-cased text
for await (const chunk of upper) {
console.log(chunk);
}
A TransformStream sits between a readable and a writable
side, letting a pipeline reshape data chunk by chunk without ever
buffering the whole thing — the same backpressure guarantee from
earlier applies across every stage: a slow consumer at the end of the
chain naturally slows down every stage before it, instead of one
stage racing ahead and burying the next in memory.
Web Workers, SharedArrayBuffer, Atomics
A regular Worker (like the one this very playground runs your code
in, so a hung loop can't freeze the tab) communicates with the main
thread by copying messages via postMessage — even
a huge object gets serialized, sent, and rebuilt on the other side.
SharedArrayBuffer is the exception: actual shared memory
both threads can read and write directly, no copying.
⚠ Shared memory needs its own locking
Two threads writing the same
SharedArrayBuffer at once
is a real, classic race condition — JS's usual single-threaded
"nothing interrupts mid-statement" guarantee doesn't cover memory two
separate threads can both touch simultaneously.
Atomics.wait/Atomics.notify /
Atomics.add exist specifically to coordinate that
safely, the same job a mutex does in a traditionally threaded
language. This is genuinely rare in day-to-day app code — mostly
reserved for CPU-heavy work like audio/video processing or a WASM
module that needs real shared-memory parallelism.
Concurrency control — running a lot of things, but not all at once
Promise.all runs everything at once. Sometimes that's wrong — 500 requests fired simultaneously can overwhelm a server or hit a rate limit. A concurrency-limited queue runs a fixed number in flight, always starting the next one the moment a slot frees up.
function wait(ms, label) {
return new Promise((resolve) => setTimeout(() => resolve(label), ms));
}
async function runWithLimit(tasks, limit) {
const results = [];
let index = 0;
async function worker() {
while (index < tasks.length) {
const current = index++;
results[current] = await tasks[current]();
}
}
await Promise.all(Array.from({ length: limit }, worker));
return results;
}
const order = [];
const tasks = [1, 2, 3, 4, 5].map((n) => async () => {
order.push("start " + n);
await wait(10);
order.push("end " + n);
return n * 10;
});
const results = await runWithLimit(tasks, 2);
console.log("results:", results);
console.log("order:", order.join(", "));
Watch the order: start 1, start 2 — only two run
immediately, the limit — then each end is immediately
followed by the next start, never more than two "start"s
without a matching "end" between them. That's the whole
pattern: a fixed pool of worker() functions, all sharing
one index counter, each one pulling the next task the
moment it's free. This exact shape — sometimes called a
semaphore when the limit is explicit — is what a real batch
job (upload 500 files, 6 at a time) is built on.
Dry run: the start/end order for two workers, five tasks
| Order | Event | Why |
|---|---|---|
| 1 | start 1 | worker A calls tasks[0](), which pushes "start 1" immediately, then awaits wait(10) |
| 2 | start 2 | worker B calls tasks[1]() the same way — 2 tasks now in flight, the limit |
| 3 | end 1 | worker A's timer (registered first) fires; task 1's wait(10) resolves and it pushes "end 1" |
| 4 | start 3 | still inside that same resolution, worker A's while loop immediately pulls index 2 and calls tasks[2]() |
| 5 | end 2 | worker B's timer (registered second, same 10ms delay) fires next; task 2 pushes "end 2" |
| 6 | start 4 | worker B immediately pulls index 3 and calls tasks[3]() |
| 7 | end 3 | worker A's second timer (started right after "start 3") fires |
| 8 | start 5 | worker A pulls the last index, 4, and calls tasks[4]() |
| 9 | end 4 | worker B's second timer fires; index is now 5, so worker B's while loop exits |
| 10 | end 5 | worker A's third timer fires; its while loop exits too, and Promise.all settles |
Every "end" is immediately followed by that same worker's next "start," in the same microtask turn — which is exactly why the log never shows more than two "start"s without a matching "end" between them: the limit is enforced by there being only two worker() loops pulling from one shared index, not by any explicit counter check.
Interview answer, one sentence
"Microtasks drain completely before the next task, so a chain of microtasks can starve rendering; generators and async iterators are pausable functions that make streams and backpressure possible, and anything CPU-heavy belongs in a worker instead of on the main thread."
Before you move on
- Explain why
queueMicrotask(loopForever)freezes the page permanently, while a same-shapesetTimeoutrecursion would not. - Explain how Node's phased event loop differs from the browser's single macrotask queue, and what
setImmediateguarantees oversetTimeout(fn, 0). - Explain what
.next(value)does to a pausedyieldexpression, and why the very first.next()call can't deliver a value into anything. - Explain how backpressure keeps a fast stream producer from burying a slow consumer in memory.
- Trace the
runWithLimitexample's start/end order for a limit of 2, and explain why the log never shows more than two "start"s without a matching "end" between them.
Opens in the editor: write it, run it, and check it against real tests.
All 31 for this level →Finished reading?
0 of 41 chapters read in this series