🎯 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
awaitdoes to a Promise that is pending, fulfilled, or rejected - Trace how
awaityields to the event loop and why microtasks run before timers - Catch asynchronous errors with
try/catchand clean up reliably withfinally - 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.
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.
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.
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).
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:
- Accept a
urland return the parsed JSON body. - Throw a custom
HttpError(with the status code) whenresponse.okis false. - Retry up to twice on server errors (status ≥ 500) or network failures, with a short backoff.
- 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
awaitpauses 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 infinally, and rememberfetchdoesn'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.