Skip to the chapter
←JavaScriptGGroundworkJSJavaScript
JavaScript/Advanced/A2
30 / 41 written chapters
/

The cover
  1. B1Setup & mental model
  2. B2Execution context
  3. B3var, let & const
  4. B4Scope
  5. B5Single thread
  6. B6In the browser
  7. B7Types & values
  8. B8Strings & Math
  9. B9Operators & flow
  10. B10Functions
  11. B11Objects & arrays
  12. B12this
  13. B13Closures
  14. B14DOM & events
  15. B15Callbacks & promises
  16. B16Errors & tools
  17. B17Guided project
  18. ★Cheat page
  1. I1Scope & functions, properly
  2. I2Objects deeply
  3. I3Prototypes & OOP
  4. I4Async, properly
  5. I5Modules & tooling
  6. I6Regex & dates
  7. I7Error handling
  8. I8Real-time connections
  9. I9Offline & storage
  10. I10Browser APIs
  11. I11Modern JS
  12. ★Cheat page
  1. A1Engine & memory
  2. A2Advanced async
  3. A3Metaprogramming
  4. A4Types & data
  5. A5Patterns & architecture
  6. A6Performance
  7. A7Security
  8. A8Ecosystem
  9. A9Testing
  10. A10Observability
  11. A11Interview bank
  12. A12Build it yourself
  13. ★Cheat page

JSA2 · Part 3 · Advanced

Advanced async

Past promises: pausable functions, streams, and running real work concurrently.

9 min read11 sections2 exercises
30 of 41

In this chapter

  • 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

and 6 more

about 9 min
11On this page
  1. Microtask starvation
  2. Node's event loop has phases; the browser's doesn't
  3. Generators — functions that pause
  4. Generators can be told to stop or fail — .return() and .throw()
  5. Iterator helpers — array methods, without building an array first
  6. Array.fromAsync — collecting an async iterable into a real array
  7. Async generators and for await...of
  8. Streams and backpressure
  9. TransformStream — reshaping a stream mid-pipeline
  10. Web Workers, SharedArrayBuffer, Atomics
  11. 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

OrderEventWhy
1start 1worker A calls tasks[0](), which pushes "start 1" immediately, then awaits wait(10)
2start 2worker B calls tasks[1]() the same way — 2 tasks now in flight, the limit
3end 1worker A's timer (registered first) fires; task 1's wait(10) resolves and it pushes "end 1"
4start 3still inside that same resolution, worker A's while loop immediately pulls index 2 and calls tasks[2]()
5end 2worker B's timer (registered second, same 10ms delay) fires next; task 2 pushes "end 2"
6start 4worker B immediately pulls index 3 and calls tasks[3]()
7end 3worker A's second timer (started right after "start 3") fires
8start 5worker A pulls the last index, 4, and calls tasks[4]()
9end 4worker B's second timer fires; index is now 5, so worker B's while loop exits
10end 5worker 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-shape setTimeout recursion would not.
  • Explain how Node's phased event loop differs from the browser's single macrotask queue, and what setImmediate guarantees over setTimeout(fn, 0).
  • Explain what .next(value) does to a paused yield expression, 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 runWithLimit example'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.

02Practice this chapter

Opens in the editor: write it, run it, and check it against real tests.

  • 10 testsTake N values from any iterableadvanced
  • 9 testsCap how many tasks run at onceadvanced
All 31 for this level →
A2

Finished reading?

0 of 41 chapters read in this series

→Next stop

  1. A1Previous · A1Engine & memory
  2. A2you are here
  3. Next · A3MetaprogrammingCode that changes how ordinary-looking code behaves.10 min read

01On this page

11 sections · 0%

  1. 01Microtask starvation
  2. 02Node's event loop has phases; the browser's doesn't
  3. 03Generators — functions that pause
  4. 04Generators can be told to stop or fail — .return() and .throw()
  5. 05Iterator helpers — array methods, without building an array first
  6. 06Array.fromAsync — collecting an async iterable into a real array
  7. 07Async generators and for await...of
  8. 08Streams and backpressure
  9. 09TransformStream — reshaping a stream mid-pipeline
  10. 10Web Workers, SharedArrayBuffer, Atomics
  11. 11Concurrency control — running a lot of things, but not all at once

[] chapters · t top