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%
I4

Async, properly

Promises, done right — and the sequential-vs-parallel mistake almost everyone makes once.

The event loop itself — call stack, microtask queue, why a 0ms timer still loses to a promise — already got two full step-through demos back in the mental model chapter. If that ordering isn't solid yet, that's the place to build it; this chapter assumes it and moves straight to the layer on top: what a Promise actually is, and how to not shoot yourself in the foot with await.

A promise has exactly three states

State Meaning Can it change again?
pendingnot settled yetyes — to fulfilled or rejected
fulfilledsucceeded, has a valueno — permanent
rejectedfailed, has a reasonno — permanent

"Settled" means fulfilled or rejected — either way, done, forever. A promise can only make that transition once; every .then()/.catch() attached to it (even attached late, after it already settled) gets called with that same final outcome.

fetch("/api/user")
  .then((response) => response.json())   // each .then returns a NEW promise
  .then((user) => console.log(user.name))
  .catch((error) => console.error("failed:", error))   // catches a rejection from ANY step above
  .finally(() => hideSpinner());          // runs either way, exactly like try/finally
Rule A single .catch() at the end of a chain catches a failure from every step before it — you don't need one per .then(). That's the real advantage over callback-style error handling from last chapter: one handler instead of one check at every level.

async / await is the same promises, different spelling

async function loadUser() {
  try {
    const response = await fetch("/api/user");
    if (!response.ok) throw new Error("Request failed: " + response.status);
    return await response.json();
  } catch (error) {
    console.error("failed:", error);
    throw error;   // re-throw so the caller still knows it failed
  }
}

Two things worth being precise about: an async function always returns a promise, even if the body has no await at all and just returns a plain value — that value gets silently wrapped. And try/catch around await catches a rejected awaited promise exactly like a thrown synchronous error — same syntax, unified handling.

The mistake: accidental sequential awaiting

function wait(ms, label) {
  return new Promise((resolve) => setTimeout(() => resolve(label), ms));
}

async function sequential() {
  const t0 = Date.now();
  await wait(50, "a");
  await wait(50, "b");
  return Date.now() - t0;
}
async function parallel() {
  const t0 = Date.now();
  await Promise.all([wait(50, "a"), wait(50, "b")]);
  return Date.now() - t0;
}

console.log("sequential ~", await sequential(), "ms");
console.log("parallel ~", await parallel(), "ms");

Roughly 100ms, then roughly 50ms. Two awaits back to back run one after the other — the second doesn't even start until the first finishes, even though the two waits have nothing to do with each other. If the work doesn't depend on the previous result, start both first (Promise.all, or just call both functions before awaiting either), and only then await. This exact mistake — awaiting three independent API calls one by one instead of together — is a very common, very real source of a slow page.

The four combinators

Call Settles when Result
Promise.all(promises)all fulfill, or the first one rejectsarray of values, in order — or rejects with that first error
Promise.allSettled(promises)every one has settled, success or failurearray of { status, value } or { status, reason } — never rejects itself
Promise.race(promises)the very first one settles, fulfilled or rejectedthat one result — could be a rejection
Promise.any(promises)the first one fulfills — ignores rejections until one succeedsthat fulfilled value, or an AggregateError if all rejected
function wait(ms, value, fails) {
  return new Promise((resolve, reject) =>
    setTimeout(() => (fails ? reject(new Error(value)) : resolve(value)), ms)
  );
}

const settled = await Promise.allSettled([
  wait(10, "ok-1"),
  wait(10, "broke", true),
]);
console.log(JSON.stringify(settled));   // what happens?

const winner = await Promise.any([
  wait(10, "fails-fast", true),
  wait(30, "succeeds-slower"),
]);
console.log(winner);   // what happens?

allSettled reports both outcomes without ever throwing — the standard choice for "run everything, tell me what worked and what didn't," like a batch upload. any returns "succeeds-slower": the early rejection doesn't disqualify the batch, any just keeps waiting until something actually succeeds — the opposite instinct from race, which would have surfaced that first rejection immediately.

AbortController — cancelling something already in flight

Promises can't be cancelled directly once started — there's no .cancel(). AbortController is the standard workaround: a signal that in-flight work can watch for, and react to by stopping itself.

async function withTimeout(taskFn, ms) {
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), ms);
  try {
    return await taskFn(controller.signal);
  } finally {
    clearTimeout(timer);   // clean up even if taskFn finished before the timeout
  }
}

fetch("/api/slow-report", { signal: controller.signal });  // fetch understands AbortSignal natively

For the specific "give up after N ms" case, there's a built-in shortcut that skips the manual timer entirely: AbortSignal.timeout(5000) returns a signal that aborts itself on schedule — pass it straight to fetch's signal option.

Retries

function wait(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

async function retry(fn, attempts, delay) {
  for (let i = 0; i < attempts; i++) {
    try {
      return await fn();
    } catch (error) {
      if (i === attempts - 1) throw error;   // out of attempts — let the real error surface
      await wait(delay);
    }
  }
}

let tries = 0;
async function flaky() {
  tries++;
  if (tries < 3) throw new Error("not ready yet");
  return "succeeded on attempt " + tries;
}

console.log(await retry(flaky, 5, 10));   // what happens?

"succeeded on attempt 3" — the first two calls throw and get swallowed (with a delay between attempts), the third succeeds and its result is what retry finally returns. A real implementation almost always adds exponential backoff — delay * 2 ** i instead of a fixed delay — so retries space out instead of hammering a struggling server at a constant rate.

fetch, past the surface level

const response = await fetch("/api/users", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ name: "Ana" }),
});

response.ok;              // true for 200-299 — already covered, still the #1 fetch mistake to forget
response.status;          // 201, 404, 500, …
response.headers.get("content-type");   // header access is case-insensitive

CORS, briefly: a browser blocks a script on a.com from reading a response from b.com unless b.com's server explicitly opts in with an Access-Control-Allow-Origin response header. This is enforced by the browser, not the server — the request usually still reaches the server and can still have side effects; the browser just refuses to hand the response back to your JavaScript. It's a client-side protection for the person visiting the page, not a way for a server to protect itself from being called.

⚠ A CORS error is almost never a JS bug If a request works fine in Postman/curl but fails only from the browser with a CORS message in the console, the fix is server-side (adding the right header) — there is no client-side JavaScript workaround for a server that hasn't opted in.
Practice this layer

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

Sync, microtask, macrotask3 tests · intermediateThree awaits in a row → one wait3 tests · advancedretry() a flaky promise4 tests · advanced
←previousPrototypes & OOP↑ CovernextModules & tooling→