Skip to main content
CodeOath
← All posts

JavaScript140 min total · 15 parts

JavaScript Core Concepts: Scope, Closures, `this`, and the Event Loop

Part 11 of 15 · ~7 min

async/await: Promises With Synchronous-Looking Syntax

async/await is not a third way of doing asynchrony. It is Promises with different punctuation — the same objects, the same queues, the same ordering rules, written so that the happy path reads top to bottom.

Here is the chain from the last chapter, rewritten:

async function loadResults(query) {
  const results = await fetchResults(query);  // pauses THIS function until it settles
  const ranked = rankByRecency(results);
  renderRows(ranked);
}

Two facts do all the work.

An async function always returns a Promise. Whatever you return becomes the fulfilment value; whatever you throw becomes the rejection reason. loadResults above has no return statement, so it returns a Promise that fulfils with undefined — still a Promise, still awaitable, still something a caller can attach a .catch to.

await pauses only the function it is written in. It does not block the page, the thread, or the caller. The engine puts the rest of loadResults aside, returns to whoever called it, and carries on running everything else — other handlers, other timers, the browser's own work. When the awaited Promise settles, the rest of loadResults picks up where it stopped.

That second fact is where the two classic bugs come from, and they pull in opposite directions.

A common gotcha: forgetting to await

async function handleInput(event) {
  spinnerEl.hidden = false;
  loadResults(event.target.value);  // no await — this line finishes instantly
  spinnerEl.hidden = true;          // runs right now, before any results exist
}

The spinner does not appear. Again. Chapter seven's spinner never appeared because the stack never emptied; this one never appears because we hid it before the work had even started. Same symptom, opposite cause, and the two are worth being able to tell apart on sight.

loadResults(...) returns a pending Promise, which this code drops on the floor. Execution continues to the next line immediately. That is not a malfunction — it is exactly what an un-awaited call means — but it is almost never what the person writing it intended.

The dangerous version of the same mistake is a dropped rejection:

loadResults(query);  // if this rejects, no catch in this file will ever see it

No await, no .catch, so nothing is handling the rejection. In a browser it surfaces as an unhandledrejection event and a console warning; in Node it terminates the process by default. Either way your try/catch around the call site does nothing, because the throw happens long after that block has exited. If you deliberately want to start something and not wait for it, say so explicitly:

loadResults(query).catch((err) => console.error("background search failed", err));

A common bug: sequential await where concurrent would do

This is the loadDropdown problem from the last chapter, stated as a rule.

// Sequential — correct here, because the second call needs the first one's answer
async function loadTopArticleBody(query) {
  const results = await fetchResults(query);
  return fetchArticleBody(results[0].id);  // cannot start before `results` exists
}

// Sequential — a bug here, because neither call needs the other
async function loadDropdown(query) {
  const results = await fetchResults(query);
  const popular = await fetchPopularSearches();
  return { results, popular };
}

// Concurrent — both start immediately, total time is the slower one
async function loadDropdown(query) {
  const [results, popular] = await Promise.all([
    fetchResults(query),
    fetchPopularSearches(),
  ]);
  return { results, popular };
}

Ask one question at every await: does the line below this actually need this value? If yes, sequential is correct and unavoidable. If no, you have quietly added one round trip's latency to a keystroke, and nobody will ever see it in a code review because the code reads perfectly well.

You can also start both and await them afterwards, which works:

const resultsPromise = fetchResults(query);        // starts now
const popularPromise = fetchPopularSearches();     // also starts now
const results = await resultsPromise;
const popular = await popularPromise;

It has one sharp edge, though. If popularPromise rejects while we are still waiting on resultsPromise, nothing is handling that rejection yet, and the runtime can report it as unhandled before execution ever reaches the second await. Promise.all attaches handlers to every input immediately, which is one more reason to prefer it over hand-rolled fan-out.

Error handling with try/catch

Because an awaited rejection throws, ordinary try/catch is the whole error story. No special syntax:

async function loadResults(query) {
  spinnerEl.hidden = false;
  try {
    const results = await fetchResults(query);
    renderRows(rankByRecency(results));
  } catch (err) {
    renderError("Search is unavailable right now.");  // what the user sees
    console.error(err);                               // what you need to debug it
  } finally {
    spinnerEl.hidden = true;                          // both paths, always
  }
}

One boundary to be exact about: try/catch catches rejections only from Promises you actually await. This block catches nothing at all —

try {
  fetchResults(query).then(renderRows);  // not awaited
} catch (err) {
  // never runs — the try block exited long before the fetch settled
}

— because by the time the request fails, the try block has been popped off the stack and is gone. The rejection has nowhere to land.

The stale response, and why debounce does not fix it

Now the bug that separates a search box that works from one that mostly works.

Type clo, pause for a moment, then finish typing sures. Two requests go out. The first one hits a cold cache on the server and takes 400 milliseconds; the second is a cache hit and takes 90. The fast one lands first and renders the right results. Then the slow one lands, and overwrites them with results for clo.

The user is looking at a dropdown full of matches for a query they finished typing half a second ago, and the input box disagrees with the list beneath it.

It is worth naming why the debounce from chapter five does not save us here, because it feels like it should. Debounce limits how often requests go out. It does nothing about what happens when two of them are in flight at once, and a 300-millisecond debounce plus any human pause mid-word produces exactly that. Responses do not come back in the order you sent the requests, and nothing in the language makes them.

The minimal fix is a sequence token, and it is the closure chapter doing real work:

function createSearchBox(inputEl, listEl) {
  let latestRequestId = 0;  // private to this search box, survives every call — a closure

  async function loadResults(query) {
    const requestId = ++latestRequestId;      // claim a ticket for this request
    const results = await fetchResults(query);
    if (requestId !== latestRequestId) return; // a newer request started while we waited — drop this
    renderRows(results);                       // still the newest — safe to render
  }
}

Three lines. Every request takes a number on the way out and checks on the way back whether it is still the most recent one; if it is not, it renders nothing and disappears. Note that latestRequestId is one of the genuinely rare cases for let from chapter two — a value that is meant to be reassigned — and that it has to live in the enclosing scope rather than inside loadResults, or every call would get its own and the comparison would always pass.

The stronger fix actually cancels the outdated request instead of ignoring its answer:

function createSearchBox(inputEl, listEl) {
  let inFlight = null;

  async function loadResults(query) {
    inFlight?.abort();                    // cancel the previous request outright
    const controller = new AbortController();
    inFlight = controller;

    try {
      const results = await fetchResults(query, controller.signal);
      renderRows(results);
    } catch (err) {
      if (err.name === "AbortError") return;  // we cancelled it — expected, not a failure
      renderError("Search is unavailable right now.");
    }
  }
}

The AbortError check is not optional. An aborted fetch rejects, which means every single keystroke after the first would render an error banner without it. This is the one place where "distinguish the failures you caused from the failures you suffered" is load-bearing rather than pedantic.

Which to use: the token is three lines and works for any async work at all, including things with no cancellation story. AbortController also stops the server doing work nobody wants and frees the connection, which matters when your user is a fast typist on a slow network. In a real search box, both together is not overkill.