📒 problems 📖 glossary
Mock interview
46:30
ready
of 46:30
Lesson 83 · Frontend

Async JS — promises, races, cancellation

📖 Walk me through it — plain English

This lesson is about asynchronous JavaScript — "async" for short. "Asynchronous" just means "doesn't happen instantly, and we don't freeze everything while we wait." When your code asks the network for data (a "fetch"), the answer might take half a second to come back. JavaScript doesn't sit there frozen; it hands you a Promise — a placeholder object that says "I'll have the value later." A Promise ends in one of two states: resolved (the value arrived) or rejected (something went wrong). The keyword await means "pause this function until that Promise settles, then give me the value." That's the whole game.

Everyday analogy: ordering at a coffee shop. You order, and instead of standing glued to the counter, you get a buzzer (the Promise). You go sit down (the rest of your program keeps running). When the buzzer goes off, you collect your drink (the Promise resolved) — or the barista comes over to say they're out of oat milk (the Promise rejected). await is choosing to just stand and wait for the buzzer before doing your next thing.

The lesson's headline example is a typeahead: a search box that shows results as you type. The hard part is the race condition. Say you type "cat" — that's three keystrokes, "c", "ca", "cat", and each one fires off its own fetch. These three requests are now "in flight" at the same time, and the network doesn't promise to answer them in order. If the answer for "ca" arrives after the answer for "cat", you'd briefly show the wrong results. A "race condition" is exactly that: the outcome depends on which async task happens to finish first, which you don't control.

Here is how the code fixes it, line by line. An AbortController is a little remote control with one button, abort(), that cancels a fetch it's wired to.

Step 1 · A new keystroke arrives. Before doing anything, press the abort button on the previous request (ctrl?.abort()). The ?. means "only if one exists." The old, now-stale fetch is cancelled.
Step 2 · Make a fresh remote control for this keystroke (ctrl = new AbortController()) and start the fetch, handing it the controller's signal so it knows which button cancels it.
Step 3 · await the response, then await r.json() to read the body, then render the results. Because we cancelled the old one in Step 1, only the latest keystroke can ever reach this line — "latest request wins."
Step 4 · A cancelled fetch throws an error named 'AbortError'. The catch deliberately ignores that one (it's expected — we cancelled it on purpose) and only calls showError for real failures like the network dying.

Two more terms the cards mention. Sequential vs parallel: writing for (const u of urls) await fetch(u) waits for each fetch to finish before starting the next — like buying coffees one customer at a time. Promise.all(urls.map(fetch)) starts them all at once and waits for the batch — everyone orders together. The classic interview slip is accidentally doing the slow sequential version. And Promise.all vs Promise.allSettled: all gives up the instant any one fetch fails; allSettled waits for every one and hands back the full mix of successes and failures, which you want when partial results are still useful.

Why this matters: async bugs are invisible on a fast connection and only show up under real-world lag, which is exactly when users notice. Knowing to cancel stale work (AbortController) or to guard results with a request-id check is what separates a flickery, wrong UI from a correct one.

Frontend interviews almost always probe async: "fetch these in parallel," "cancel when the user types again," "what does this Promise chain print." Backend roles see it too — Node, Go contexts, Python asyncio share the shapes.

First principles: why async exists at all

Before any pattern makes sense, fix one fact in your mind: JavaScript is single-threaded. A "thread" is a single worker that executes one instruction at a time; "single-threaded" means there is exactly one such worker, and it can only do one thing at any given moment. There is no second worker quietly running your code in parallel. That sounds limiting, and it would be — except for async.

The problem async solves is blocking. Synchronous code runs top to bottom, each line finishing before the next starts; while a synchronous line is running, the one thread is busy and nothing else can happen. If a synchronous line takes 500ms (say, waiting for a server), the entire page is frozen for 500ms — clicks do nothing, animations stall, the tab appears hung. That freeze is called "blocking the main thread." Asynchronous code is the escape hatch: instead of standing still during the wait, the thread kicks off the slow operation, hands you a placeholder, and goes back to running other code. When the slow thing finishes, its follow-up work is scheduled to run later, on that same single thread, once it is free. So async is not "doing two things at once" (we only have one worker); it is "not wasting the worker's time standing around waiting." Hold that distinction — almost every async confusion dissolves once you do.

The vocabulary, defined

Every term below shows up in interviews and in the cards further down. Read these once and the rest of the lesson reads easily.

  • Synchronous — runs immediately and blocks until done. const x = 2 + 2 is synchronous: the next line waits for it (instantly here).
  • Asynchronous — starts now, finishes later, does not block the thread in the meantime. fetch(url) is asynchronous.
  • The call stack — the pile of function calls currently executing. When you call a() which calls b(), the stack is [a, b]; b finishes and pops off, then a. The single thread is always working on whatever is on top of the stack. Async work cannot run while the stack is non-empty.
  • The event loop — the dispatcher that, each time the call stack empties, pulls the next queued piece of work and runs it. It is the mechanism that lets one thread juggle many pending async operations. We trace it in detail below.
  • Callback — a function you hand to another function to be called "back" later, when something is ready. setTimeout(fn, 1000) calls fn after a second; fn is the callback.
  • Callback hell — the deeply nested, hard-to-read pyramid you get when callbacks depend on callbacks depend on callbacks. The pain that Promises were invented to cure (shown below).
  • Promise — an object representing a value that isn't ready yet. It is a placeholder you can attach follow-up work to.
  • Pending / fulfilled / rejected — a Promise's three states. Pending = still waiting. Fulfilled (also called "resolved") = succeeded, carries a value. Rejected = failed, carries an error/reason. Once it leaves pending it is settled and never changes again.
  • then / catch — methods on a Promise. .then(fn) registers fn to run with the value when it fulfills; .catch(fn) registers fn to run with the reason if it rejects. Both return a new Promise, so they chain.
  • async / await — syntax sugar over Promises. Marking a function async makes it return a Promise; inside it, await p pauses that function until Promise p settles and yields its value (or throws its rejection). It reads like synchronous code but is not blocking — only that one function is paused, the thread is free.
  • Microtask vs macrotask queue — the two waiting lines the event loop pulls from. Microtasks (Promise callbacks, queueMicrotask) are high-priority and drained completely before the next macrotask. Macrotasks (setTimeout, I/O, UI events) run one per loop turn. This priority is why a Promise .then beats a setTimeout(…, 0).
  • Promise.all / Promise.race — combinators over many Promises. all fulfills with an array of all results once every Promise fulfills, and rejects the moment any one rejects. race settles as soon as the first Promise settles (fulfilled or rejected), with that one's outcome.
  • Closure — a function that "remembers" variables from the scope it was created in, even after that scope has returned. Async callbacks rely on closures constantly: the callback you pass to .then can still see the local variables from around where you wrote it.
  • Hoisting — JavaScript "lifts" declarations to the top of their scope before running. function declarations are fully hoisted (callable before their line); var is hoisted but undefined until assigned; let/const are hoisted into a "temporal dead zone" and throw if used before their line. Relevant to async because hoisting decides what a callback can legally reference.

One task, three styles: callback → Promise → async/await

The same job — fetch a user, then fetch that user's posts — written three ways. Watch the readability climb while the meaning stays identical.

1. Callbacks. Each step nests inside the previous one's callback. With two steps it is tolerable; with five it becomes the rightward-drifting pyramid known as callback hell, and error handling has to be repeated at every level.

getUser(id, (err, user) => {
  if (err) return showError(err);
  getPosts(user.id, (err, posts) => {   // nested inside the first callback
    if (err) return showError(err);   // error handling repeated
    render(user, posts);
  });
});

2. Promises. Each step returns a Promise; .then chains them so they flatten into a vertical list instead of a pyramid, and a single .catch at the end handles a failure from any step. (Returning a Promise inside a .then makes the next .then wait for it — that is the key to keeping it flat.)

getUser(id)
  .then(user => getPosts(user.id).then(posts => ({ user, posts })))
  .then(({ user, posts }) => render(user, posts))
  .catch(showError);            // one handler for every step above

3. async/await. Now it reads like plain synchronous code, top to bottom, with an ordinary try/catch for errors — yet under the hood it is the exact same Promise chain. await simply unwraps each Promise's value for you. This is why the cards say "async/await is just sugar": every await x is x.then(…) in disguise.

async function show(id) {
  try {
    const user  = await getUser(id);    // pause until user arrives
    const posts = await getPosts(user.id); // then pause until posts arrive
    render(user, posts);
  } catch (e) {
    showError(e);                       // catches a rejection from either await
  }
}
Promise.all vs allSettled

all rejects on first failure. allSettled waits for everything regardless. Use all when one failure means the whole job is dead; allSettled when you want partial results.

Sequential vs parallel

for (const u of urls) await fetch(u) is sequential. await Promise.all(urls.map(fetch)) is parallel. The interview gotcha is when candidates accidentally serialize.

AbortController

fetch(url, { signal: ctrl.signal }). Call ctrl.abort() to cancel. Required for typeahead / "latest request wins" UIs.

Race conditions

User types 3 chars → 3 fetches in flight. They can resolve out of order. Either abort prior, or guard with a request-id check before applying results.

Microtask vs macrotask

Promise.then = microtask (runs before next paint). setTimeout = macrotask. queueMicrotask for scheduling without the 4ms timer minimum.

async/await is just sugar

await x is x.then(...) with implicit error propagation. try/catch around await replaces .catch.

Tracing the event loop — predict the output

"What does this print, and in what order?" is the single most common async interview question. The rule you need is just three steps: (1) run all the plain synchronous code first, top to bottom, until the call stack is empty; (2) then drain the entire microtask queue (Promise callbacks); (3) then run one macrotask (e.g. one setTimeout), and go back to step 2. Microtasks always outrank macrotasks.

console.log('1');                          // synchronous
setTimeout(() => console.log('2'), 0);   // macrotask
Promise.resolve().then(() => console.log('3')); // microtask
console.log('4');                          // synchronous

// Output:  1  4  3  2

Walking it: '1' and '4' are synchronous, so they print first, in order — 1, 4. The setTimeout callback goes to the macrotask queue; the .then callback goes to the microtask queue. The stack is now empty, so the event loop drains all microtasks first → 3. Only then does it run the one waiting macrotask → 2. Even with a 0ms timeout, the Promise wins, because microtasks are emptied completely before any macrotask runs. That single rule answers the vast majority of "guess the order" puzzles.

Pitfalls that bite in real code

These three account for most async bugs in production and most "what's wrong here?" interview prompts.

1. Forgotten await. If you drop the await, you get the Promise object itself, not its value — and your code marches on before the work is done.

const user = getUser(id);   // BUG: user is a Promise, not the data
render(user.name);          // undefined — Promises have no .name
const user = await getUser(id); // FIX: await unwraps the value

2. Unhandled rejection. A rejected Promise with no .catch (or no surrounding try/catch when awaited) becomes an "unhandled rejection" — it crashes Node processes and logs noisy errors in browsers. Every Promise chain needs an owner for its failure.

getUser(id).then(render);          // BUG: if it rejects, nobody catches it
getUser(id).then(render).catch(showError); // FIX

3. await inside a loop. Awaiting each item in turn serializes work that could run together — the accidental-sequential trap from the cards. Five 100ms fetches take 500ms instead of ~100ms. Use Promise.all when the items don't depend on each other.

for (const u of urls) await fetch(u);   // BUG: 5 × 100ms = 500ms, one at a time
await Promise.all(urls.map(fetch)); // FIX: all at once, ~100ms total
Typeahead — the canonical async question
let ctrl: AbortController | null = null;
async function onInput(q: string) {
  ctrl?.abort();                  // cancel in-flight
  ctrl = new AbortController();
  try {
    const r = await fetch(`/search?q=${q}`, { signal: ctrl.signal });
    render(await r.json());
  } catch (e) {
    if (e.name !== 'AbortError') showError(e);
  }
}

Go deeper (optional): MDN's "Asynchronous JavaScript" guide and Jake Archibald's talk "In The Loop" both visualise the call stack, microtask, and macrotask queues step by step — worth watching once after you can predict the output above on your own.

→ Going deeper: Async JavaScript assumes you know how the DOM updates. See Browser & DOM internals.