async and await

Write asynchronous code that reads top to bottom, and handle failure in it.

22of 32
≈ 12Min read
8Parts

The same promises, written differently

  • An async function always returns a promise, whatever you write inside it.
  • await pauses that function until the promise settles. It does not block the thread.
  • A rejected promise becomes a thrown error, so ordinary try/catch works.
  • return x inside an async function resolves its promise with x.
a chain and its await version
// with then
function load(id) {
  return getDoor(id)
    .then((door) => getPrice(door))
    .then((price) => ({ id, price }))
    .catch((err) => ({ id, error: err.message }));
}

// with await
async function load(id) {
  try {
    const door = await getDoor(id);
    const price = await getPrice(door);
    return { id, price };
  } catch (err) {
    return { id, error: err.message };
  }
}

Serial when it should be parallel

the most common performance mistake in async code
// 900ms — each waits for the last, for no reason
const user = await getUser();      // 300ms
const doors = await getDoors();    // 300ms
const prices = await getPrices();  // 300ms

// 300ms — they overlap
const [user, doors, prices] = await Promise.all([
  getUser(), getDoors(), getPrices(),
]);

// or start them, then await
const userP = getUser();
const doorsP = getDoors();
const user = await userP;
const doors = await doorsP;
Await serially only when you have to

If the second call needs the first one's result, sequential is correct. If it does not, it is just slower.

await in a loop

sometimes right, sometimes a bug
// deliberate: one at a time, in order
for (const id of ids) {
  await save(id);
}

// all at once
await Promise.all(ids.map((id) => save(id)));

// THIS DOES NOTHING — forEach ignores the returned promise
ids.forEach(async (id) => { await save(id); });
console.log("done");     // logs immediately, nothing has saved yet
await inside forEach, map or filter

map gives you an array of promises, which is useful with Promise.all. forEach and filter simply discard them — filter(async …) keeps everything, because a promise is truthy.

Errors

Do not wrap every single await in its own try. Group the ones that fail for the same reason and are handled the same way.

three placements
// around one call
try {
  const data = await load();
} catch (err) {
  show(err.message);
}

// around a whole block, when any of it failing means the same thing
try {
  const a = await one();
  const b = await two(a);
  render(b);
} catch (err) {
  showError(err);
} finally {
  spinner.remove();
}

// or handle it at the call and keep going
const [err, data] = await load().then((d) => [null, d], (e) => [e, null]);

Async functions in the wrong place

The same applies to setTimeout(async …), an async forEach callback, and a top-level call with no .catch(). If nobody is awaiting the promise, nobody sees the error.

the rejection nobody catches
// an async event handler: nothing is awaiting it, so a throw is unhandled
button.addEventListener("click", async () => {
  await save();               // if this rejects, it is an unhandled rejection
});

// handle it inside
button.addEventListener("click", async () => {
  try {
    await save();
  } catch (err) {
    showError(err);
  }
});

Top-level await

A module that uses top-level await delays every module that imports it until it settles. That is occasionally what you want for configuration, and a good way to slow a page down otherwise.

in a module only
// app.js, loaded with type="module"
const config = await fetch("/config.json").then((r) => r.json());
start(config);

Cancelling

An aborted fetch rejects with an AbortError. Treating that as an error shows the reader a message about something they caused on purpose — check the name and return quietly.

a promise cannot be cancelled, but the work can
const ac = new AbortController();

async function search(q) {
  ac.abort();                       // cancel the previous one
  const controller = new AbortController();
  try {
    const res = await fetch(`/search?q=${q}`, { signal: controller.signal });
    return await res.json();
  } catch (err) {
    if (err.name === "AbortError") return null;   // expected, not a failure
    throw err;
  }
}

Retrying

Retry only what is worth retrying: a network failure or a 503, not a 400. Retrying a request the server rejected on purpose just makes the same mistake three times.

with a growing delay
async function retry(fn, times = 3, wait = 300) {
  let lastError;
  for (let i = 0; i < times; i++) {
    try {
      return await fn();
    } catch (err) {
      lastError = err;
      if (i < times - 1) await new Promise((r) => setTimeout(r, wait * 2 ** i));
    }
  }
  throw lastError;
}

Practice