Advanced async
Past promises: pausable functions, streams, and running real work concurrently.
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 sample yourself into starvation 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."
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.
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.
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.
Opens in the editor — write it, run it, and check it against real tests.