Skip to the chapter
←JavaScriptGGroundworkJSJavaScript
JavaScript/Intermediate/I4
21 / 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

JSI4 · Part 2 · Intermediate

Async, properly

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

10 min read13 sections9 exercises
21 of 41

In this chapter

  • The mistake: accidental sequential awaiting
  • forEach doesn't wait — the async loop trap
  • The four combinators
  • Promise.all does not cancel the others
  • Unhandled rejections

and 8 more

about 10 min
13On this page
  1. The mistake: accidental sequential awaiting
  2. forEach doesn't wait — the async loop trap
  3. The four combinators
  4. Promise.all does not cancel the others
  5. Unhandled rejections
  6. Predict the order
  7. AbortController — cancelling something already in flight
  8. The stale-response race — "latest request wins"
  9. Promise.withResolvers — resolve/reject without smuggling them out
  10. Anything with a .then is a thenable — await doesn't require a real Promise
  11. Linking multiple abort reasons
  12. Retries
  13. fetch, past the surface level

Three chapters already did the groundwork this one stands on: why async exists at all, the ordering demos, and promises themselves — the three states, chaining, .catch, and async/await as the same thing in different spelling. This chapter assumes all of that and goes to the layer above it: the mistakes that survive knowing the syntax.

One-line recap → A promise settles once, permanently, and every reaction on it runs as a microtask. .then returns a new promise whose value is whatever your callback returned. An async function always returns a promise; await unwraps one and throws on rejection.

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.

forEach doesn't wait — the async loop trap

function save(item) {
  return new Promise((resolve) => setTimeout(resolve, 50));
}

async function processAll(items) {
  items.forEach(async (item) => {
    await save(item);          // forEach has ALREADY moved to the next item by the time this resolves
  });
  console.log("done!");         // what happens?
}
processAll([1, 2, 3]);

"done!" logs almost immediately — before any of the three saves finish. forEach calls its callback three times and never looks at what any of them return; an async callback still returns a promise, forEach just throws every one of those promises away unread. The fix is a plain for...of loop (sequential, each await genuinely pauses the outer function) or await Promise.all(items.map((i) => save(i))) (parallel, and the outer function actually waits for all three before logging).

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.

Promise.all does not cancel the others

const [report, user] = await Promise.all([
  fetch("/slow-report"),                 // keeps running after the line below rejects
  Promise.reject(new Error("nope")),
]);

Promise.all rejects the moment any input rejects, but it cannot stop the rest. Their requests carry on, their side effects still happen, and their results are quietly discarded. Nothing in the language can cancel a promise; cancellation is something you build with a signal. Give every task the same AbortSignal and abort on failure:

const controller = new AbortController();
try {
  return await Promise.all(urls.map((u) => fetch(u, { signal: controller.signal })));
} catch (err) {
  controller.abort();                    // stop the siblings, don't just ignore them
  throw err;
}

Promise.all does attach a handler to every input, so a rejection that arrives after the first one is not reported as unhandled. That keeps the console quiet, and it is also why a failure can vanish without a trace.

Unhandled rejections

async function save() { throw new Error("disk full"); }

save();                                  // no await, no .catch — nobody handles it

A promise that rejects with no handler attached becomes an unhandled rejection, and the platform decides what that means. In a browser it fires an unhandledrejection event on window and logs an error; the page keeps running. In Node.js since version 15 the default is to treat it as an uncaught exception, which crashes the process. The usual source is a fire-and-forget async call.

window.addEventListener("unhandledrejection", (e) => {
  report(e.reason);                      // last-resort logging, not error handling
});

process.on("unhandledRejection", (reason) => {   // Node
  report(reason);
});

save().catch(report);                    // the real fix: every promise ends in a handler

Predict the order

console.log("1");
setTimeout(() => console.log("2"), 0);
Promise.resolve().then(() => console.log("3"));
(async () => {
  console.log("4");
  await null;
  console.log("5");
})();
queueMicrotask(() => console.log("6"));
console.log("7");

Dry run: the order example above, one row per print

PrintsComes fromWhy it runs when it does
"1"synchronous console.logruns immediately, top of the script
"4"synchronous, inside the async IIFEan async function's body runs synchronously up to its first await
"7"synchronous console.loglast line of the script — the call stack is now empty
"3"microtask — .then() on an already-resolved promisequeued 1st, before the async IIFE even ran
"5"microtask — the async function's continuation after await nullqueued 2nd
"6"microtask — queueMicrotaskqueued 3rd — the microtask queue is FIFO, so it still runs after 3 and 5
"2"macrotask — the setTimeout callbackruns only once the entire microtask queue is empty, even though it was scheduled first

Two rules do all the work here: every synchronous line — including an async function's body up to its first await — finishes before any microtask runs, and every microtask already queued drains before the event loop even looks at the next macrotask, regardless of scheduling order.

The output is 1, 4, 7, 3, 5, 6, 2. Step by step: the synchronous code runs first and prints 1; setTimeout only schedules a task; the .then callback is queued as a microtask; the async function runs synchronously up to its first await, printing 4, and its continuation is queued as a second microtask; queueMicrotask queues a third; then 7 prints and the call stack empties. Now the microtask queue drains in order — 3, 5, 6 — and only after it is empty does the event loop take the timer's task and print 2.

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
  }
}

const report = await withTimeout(
  (signal) => fetch("/api/slow-report", { signal }),   // fetch understands AbortSignal natively
  5000
);

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.

The stale-response race — "latest request wins"

A search box that fires a request on every keystroke has a race built in: nothing guarantees responses arrive in the same order the requests left. Type "re", then "rea" a moment later — if the "re" request happens to take longer, its (now-stale) results can land after "rea"'s and overwrite the correct ones on screen.

let latestId = 0;

async function search(query, render) {
  const id = ++latestId;                     // this request's own ticket number
  const results = await fetchResults(query);
  if (id !== latestId) return;                // a newer search started while this was in flight — drop it
  render(results);
}

function fetchResults(query) {
  const delay = query.length === 2 ? 300 : 30;   // simulate "re" being the slow one
  return new Promise((resolve) => setTimeout(() => resolve("results for " + query), delay));
}

const rendered = [];
search("re", (r) => rendered.push(r));
search("rea", (r) => rendered.push(r));
await new Promise((r) => setTimeout(r, 400));
console.log(rendered);   // what happens?

["results for rea"] — only one result ever renders, even though both requests actually completed. Each call captures its own id before awaiting; by the time the slow "re" response comes back, latestId has already moved on to "rea"'s ticket, so the stale one silently drops itself instead of overwriting the screen. AbortController solves the same problem a different way — cancel the previous request outright instead of letting it finish and checking afterward — and is the better choice whenever the request itself is expensive enough that abandoning it early actually saves real work, not just a render.

Promise.withResolvers — resolve/reject without smuggling them out

function createDeferred() {                                        // the old way
  let resolve, reject;
  const promise = new Promise((res, rej) => { resolve = res; reject = rej; });
  return { promise, resolve, reject };
}

// the same thing, built in:
const { promise, resolve, reject } = Promise.withResolvers();

A "deferred" — a promise whose resolve/reject are usable from outside its own executor — used to require the slightly awkward pattern shown above, capturing the callbacks into outer variables. Promise.withResolvers() is exactly that pattern, standardized. It's useful anywhere a promise needs to be settled by something other than its own executor — bridging an event-based API, or a queue where one function enqueues work and a separate callback resolves it later.

Anything with a .then is a thenable — await doesn't require a real Promise

const thenable = {
  then(resolve) {
    setTimeout(() => resolve(42), 10);
  },
};

console.log(await thenable);   // what happens?

42 — await (and Promise.resolve, and .then chaining) don't check for instanceof Promise; they check for a callable .then method, full stop. That's how libraries which predate native promises interoperate with await with no adapter needed — and it's also a real gotcha: an object that merely happens to have a property named then, for entirely unrelated reasons, gets treated as a promise by anything that awaits it.

Linking multiple abort reasons

const userCancel = new AbortController();
const timeout = AbortSignal.timeout(5000);

const signal = AbortSignal.any([userCancel.signal, timeout]);   // fires when EITHER does
fetch("/api/report", { signal });

cancelButton.addEventListener("click", () => userCancel.abort());

AbortSignal.any(signals) returns one combined signal that aborts the moment any of its inputs does, carrying whichever reason fired first — the standard way to give one request both a timeout and a user-triggered cancel button without wiring two separate abort listeners by hand.

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 — still the #1 fetch mistake to forget
response.status;          // 201, 404, 500, …
response.headers.get("content-type");   // header access is case-insensitive

The response.ok check is already covered; forgetting it is still the most common fetch mistake.

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. Whether the request itself reaches the server depends on what kind of request it is, which is the next box. Either way it's a client-side protection for the person visiting the page, not a way for a server to protect itself from being called.

Simple requests and preflighted requests

A simple request is a GET, HEAD or POST with only browser-safe headers and a Content-Type of application/x-www-form-urlencoded, multipart/form-data or text/plain. The browser sends it straight away with an Origin header. The server processes it, side effects included, and the browser then withholds the response unless Access-Control-Allow-Origin permits it.

Anything else is preflighted: PUT, PATCH, DELETE, a custom header such as Authorization, or Content-Type: application/json — which is the POST above. The browser first sends an OPTIONS request asking permission, and only sends the real one if the answer is yes.

OPTIONS /users            // the preflight
Origin: https://a.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type

204 No Content            // the server's answer
Access-Control-Allow-Origin: https://a.com
Access-Control-Allow-Methods: POST
Access-Control-Allow-Headers: content-type

POST /users               // sent only now

So a blocked JSON POST usually never reaches the server at all, while a blocked form-style POST usually does. See the security chapter for the preflight from the server's side.

⚠ 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.
Interview answer, one sentence

"Independent awaits should run in parallel with Promise.all, but Promise.all cannot cancel its siblings — real cancellation needs an AbortSignal — and every promise must end in a handler, because an unhandled rejection crashes Node and is only logged in a browser."

Before you move on
  • Explain why two sequential awaits take twice as long as Promise.all on independent work, and rewrite a sequential-await function to run in parallel.
  • Explain why items.forEach(async ...) doesn't actually wait for anything, and name two ways to fix it.
  • State what each of Promise.all, allSettled, race, and any does differently the moment one input rejects.
  • Explain why Promise.all can't cancel its sibling requests, and how AbortController fixes that.
  • Trace the "predict the order" snippet's output from memory: sync code, then microtasks, then the timer.

09Practice this chapter

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

  • 9 testsSync, microtask, macrotaskintermediate
  • 8 testsThree awaits in a row → one waitadvanced
  • 10 testsretry() a flaky promiseadvanced
  • 10 testsFix the forEach that doesn't actually waitintermediate
  • 8 testsImplement Promise.allintermediate
  • 8 testsImplement Promise.allSettledintermediate
  • 8 testsImplement Promise.anyintermediate
  • 8 testsImplement Promise.raceintermediate
  • 9 testsRun async work with a concurrency limitintermediate
All 33 for this level →
I4

Finished reading?

0 of 41 chapters read in this series

→Next stop

  1. I3Previous · I3Prototypes & OOP
  2. I4you are here
  3. Next · I5Modules & toolingEverything that turns files full of JS into one thing a browser can run.7 min read

01On this page

13 sections · 0%

  1. 01The mistake: accidental sequential awaiting
  2. 02forEach doesn't wait — the async loop trap
  3. 03The four combinators
  4. 04Promise.all does not cancel the others
  5. 05Unhandled rejections
  6. 06Predict the order
  7. 07AbortController — cancelling something already in flight
  8. 08The stale-response race — "latest request wins"
  9. 09Promise.withResolvers — resolve/reject without smuggling them out
  10. 10Anything with a .then is a thenable — await doesn't require a real Promise
  11. 11Linking multiple abort reasons
  12. 12Retries
  13. 13fetch, past the surface level

[] chapters · t top