Skip to main content

🎯 The Await Keyword and Error Handling

If async is the doorway, await is what happens inside the room. This lesson explains precisely what await pauses, what it evaluates to, how it hands control back to the event loop, and how to catch and recover from failures using familiar try/catch/finally.

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Describe exactly what await does to a Promise that is pending, fulfilled, or rejected
  • Trace how await yields to the event loop and why microtasks run before timers
  • Catch asynchronous errors with try/catch and clean up reliably with finally
  • Design custom error classes so callers can react to different failure types
  • Apply a retry-with-backoff pattern to survive flaky network calls

Estimated Time: 35–45 minutes  •  Difficulty: Intermediate

Hands-on: Build a resilient fetchJSON helper with typed errors and retries.

In This Lesson

What Await Really Does

The await operator does one job: it pauses the async function until the Promise beside it settles, then evaluates to that Promise's result. If the Promise fulfils, await gives you the value. If it rejects, await throws the rejection reason at that exact line — which is why try/catch works.

graph TD A["await promise"] --> B{"Promise state?"} B -->|Pending| C["Pause this function
yield to event loop"] C --> D["Resume when it settles"] D --> B B -->|Fulfilled| E["Evaluate to the value"] B -->|Rejected| F["Throw the reason
(catchable)"]
💡 Mental model: think of await as "un-wrap this Promise, or throw if it failed." The rest of your function simply waits its turn.

What Can Be Awaited?

You can await any Promise — but also any thenable (an object with a .then() method) and even plain values. Awaiting a non-Promise simply wraps it in Promise.resolve() first, so it always yields on the next microtask.

// Awaiting a native Promise
const response = await fetch('/api/data');

// Awaiting a Promise you built by hand (a 1-second delay)
await new Promise((resolve) => setTimeout(resolve, 1000));

// Awaiting a thenable — not a real Promise, but compatible
const thenable = { then: (resolve) => resolve('from a thenable') };
const a = await thenable;      // 'from a thenable'

// Awaiting a plain value — wrapped in Promise.resolve automatically
const b = await 42;            // 42 (yields once, then continues)

📖 Key Terms

Thenable: any object with a .then(onFulfilled, onRejected) method. await and Promise.resolve() both accept thenables, which is how Promise libraries interoperate.

Microtask: a high-priority job (like a resolved Promise's continuation) that runs after the current synchronous code but before timers.

Await and the Event Loop

When execution hits an await on a pending Promise, the function suspends and returns control to the event loop, which is free to run other code. When the Promise settles, resuming the function is scheduled as a microtask. Microtasks always drain before the next timer callback (a macrotask), which produces some surprising-but-correct ordering.

sequenceDiagram participant F as Async function participant EL as Event loop participant P as Promise F->>P: await promise Note over F: suspended F->>EL: control returned EL->>EL: run other tasks P-->>EL: settles EL->>F: resume (as a microtask) Note over F: continue with the value
async function order() {
  console.log('1. start');

  setTimeout(() => console.log('4. timer (macrotask)'), 0);

  await Promise.resolve();        // suspends, schedules a microtask
  console.log('3. after await (microtask)');
}

console.log('0. before call');
order();
console.log('2. after call (sync continues)');

// Console order:
// 0. before call
// 1. start
// 2. after call (sync continues)
// 3. after await (microtask)
// 4. timer (macrotask)

💡 Why the timer runs last

Even with a 0 ms timer, the continuation after await is a microtask, and microtasks have priority over timer macrotasks. The function resumes and logs before the timer callback fires.

Error Handling with try/catch

Because a rejected await throws, you handle asynchronous errors with the same try/catch you use for synchronous code. A single catch covers every await inside the try block.

A rejected await jumps to the catch block Execution enters the try block and runs an awaited operation. If it rejects, control jumps down to the catch block which handles the error. try { const data = await risky(); catch (error) { handle / log / recover on reject
Figure 1 — When an awaited Promise rejects, it throws at that line and execution jumps straight to the surrounding catch block.
async function fetchAndProcess(url) {
  try {
    const response = await fetch(url);

    // fetch only rejects on network failure — check status yourself
    if (!response.ok) {
      throw new Error(`HTTP error: ${response.status}`);
    }

    const data = await response.json();
    return process(data);
  } catch (error) {
    console.error('Request failed:', error);
    throw error; // re-throw so the caller can decide what to do
  }
}

⚠️ fetch does not reject on 404 or 500

A common surprise: fetch() only rejects on a network failure. An HTTP 404 or 500 still fulfils. Always check response.ok and throw yourself, as shown above.

Cleanup with finally

A finally block runs after try/catch no matter what — success, handled error, or re-thrown error. It's the right home for releasing resources: closing files, hiding spinners, ending database sessions.

async function loadWithSpinner(url) {
  showSpinner();
  try {
    const res = await fetch(url);
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    return await res.json();
  } catch (error) {
    console.error('Load failed:', error);
    throw error;
  } finally {
    // Runs on success AND on failure — the spinner always hides.
    hideSpinner();
  }
}

✅ Guard your cleanup too

If the cleanup itself can fail (for example closing a file handle), wrap it in its own small try/catch inside finally so a cleanup error doesn't mask the real error.

Custom Error Classes

A single generic Error tells the caller little. By subclassing Error you attach structured data (status codes, field errors) and let callers branch with instanceof.

class ApiError extends Error {
  constructor(message, status, endpoint) {
    super(message);
    this.name = 'ApiError';
    this.status = status;
    this.endpoint = endpoint;
  }
  get isClientError() { return this.status >= 400 && this.status < 500; }
  get isServerError() { return this.status >= 500; }
}

async function fetchUser(userId) {
  const response = await fetch(`/api/users/${userId}`);
  if (!response.ok) {
    throw new ApiError(`Could not load user ${userId}`, response.status, `/api/users/${userId}`);
  }
  return response.json();
}

// The caller can now react precisely to the failure type
try {
  const user = await fetchUser('123');
} catch (error) {
  if (error instanceof ApiError && error.isServerError) {
    await reportOutage(error);       // 5xx — alert the team
  } else if (error instanceof ApiError && error.isClientError) {
    showMessage('User not found');   // 4xx — tell the user
  } else {
    throw error;                     // something unexpected
  }
}

The Retry Pattern

Networks fail transiently. A retry with exponential backoff gives a flaky call a few more chances, waiting longer between each attempt (plus a little random "jitter" so many clients don't all retry in lockstep).

graph TD A[Attempt operation] --> B{Succeeded?} B -->|Yes| C[Return result] B -->|No| D{Retries left?} D -->|No| E[Throw the error] D -->|Yes| F[Wait: backoff plus jitter] F --> A
async function fetchWithRetry(url, { maxRetries = 3 } = {}) {
  for (let attempt = 1; ; attempt++) {
    try {
      const response = await fetch(url);
      if (!response.ok) throw new Error(`HTTP ${response.status}`);
      return await response.json();
    } catch (error) {
      if (attempt > maxRetries) {
        console.error(`Giving up after ${maxRetries} retries`);
        throw error;
      }
      // 2^attempt seconds, capped at 10s, plus up to 1s of jitter
      const delay = Math.min(1000 * 2 ** attempt, 10000) + Math.random() * 1000;
      console.warn(`Attempt ${attempt} failed; retrying in ${Math.round(delay)}ms`);
      await new Promise((resolve) => setTimeout(resolve, delay));
    }
  }
}

💡 Only retry what's safe

Retry idempotent operations (GETs, safe reads). Blindly retrying a "charge card" request could bill a customer twice. Gate retries on the error type — for example, only on 5xx and network errors.

Hands-on Exercise

🏋️ Build a Resilient fetchJSON

Objective: Combine everything above into one reusable helper.

Requirements:

  1. Accept a url and return the parsed JSON body.
  2. Throw a custom HttpError (with the status code) when response.ok is false.
  3. Retry up to twice on server errors (status ≥ 500) or network failures, with a short backoff.
  4. Do not retry on client errors (status 400–499) — fail fast.
💡 Hint

Give HttpError a status field. In the catch block, decide whether to retry: retry when there's no status (network error) or when status >= 500; otherwise re-throw immediately.

✅ Solution
class HttpError extends Error {
  constructor(status, url) {
    super(`HTTP ${status} for ${url}`);
    this.name = 'HttpError';
    this.status = status;
  }
}

async function fetchJSON(url, { maxRetries = 2 } = {}) {
  for (let attempt = 0; ; attempt++) {
    try {
      const response = await fetch(url);
      if (!response.ok) throw new HttpError(response.status, url);
      return await response.json();
    } catch (error) {
      const isServer = error instanceof HttpError && error.status >= 500;
      const isNetwork = !(error instanceof HttpError);
      const retryable = isServer || isNetwork;

      if (!retryable || attempt >= maxRetries) throw error;

      const delay = 300 * 2 ** attempt + Math.random() * 200;
      await new Promise((resolve) => setTimeout(resolve, delay));
    }
  }
}

Client errors (like 404) throw immediately; server and network hiccups get two more tries before giving up.

🎯 Quick Quiz

Question 1: What happens when you await a Promise that rejects?

Question 2: A fetch() to a URL returns HTTP 500. What does the Promise do?

Question 3: Why does the continuation after await Promise.resolve() run before a setTimeout(fn, 0) callback scheduled earlier?

Summary & Quiz

🎉 Key Takeaways

  • await pauses the function until a Promise settles, then yields its value or throws its rejection.
  • You can await Promises, thenables, and plain values (which get wrapped in Promise.resolve()).
  • Await suspends to the event loop; the resume is a microtask, so it beats timer callbacks.
  • Handle failures with try/catch, always clean up in finally, and remember fetch doesn't reject on 4xx/5xx.
  • Custom error classes plus retry-with-backoff make async code robust in the real world.

📚 Further Reading

🚀 What's Next?

You can now await confidently and recover from failures. Next we assemble these building blocks into reusable async/await patterns — waterfalls, parallel fan-out, batching, and circuit breakers — that show up in production code every day.

🎉 Great work!

Solid error handling is what separates a demo from a dependable app.