Skip to the notes
JSGroundwork
JSGroundwork handwritten · web dev
✎Playground→⌘Problems↻Review🔥Progress

Chapters

27 chapters
⌕
Beginner9›
B1Setup & mental modelB2Types & valuesB3Operators & flowB4Functions (first half)B5Objects & arrays (first half)B6DOM & eventsB7Basic asyncB8Errors & tools★Cheat page
Intermediate10›
I1Scope & functions, properlyI2Objects deeplyI3Prototypes & OOPI4Async, properlyI5Modules & toolingI6Regex, dates & APIsI7Error handlingI8Real-time connectionsI9Offline & storage★Cheat page
Advanced10›
A1Engine & memoryA2Advanced asyncA3MetaprogrammingA4Types & dataA5Patterns & architectureA6PerformanceA7SecurityA8EcosystemA9Testing★Cheat page
/ search[ ] chaptert top

JavaScript levels

1Beginner2Intermediate3Advanced

Ready to read

JSJavaScript⑂Git◎Interview prepΣDSA in JSSDSystem Design
More topics15›
</>HTML{ }CSS⚛ReactNNext.jsNeNest.jsTSTypeScriptNoNode.js🐳DockerDBSQL & Databases✓Testing🔒Web Security☁Cloud & DevOps◈GraphQL◆Redis☸Kubernetes
100%
A2

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.

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

Practice this layer

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

Take N values from any iterable4 tests · advancedCap how many tasks run at once2 tests · advanced
←previousEngine & memory↑ CovernextMetaprogramming→