Handling errors in async code
Module 5 taught throw and try/catch for code that runs now. Async code
breaks two of the assumptions underneath it: the error arrives after your try
block has finished, and there are three ways to fail rather than one.
This lesson is about where to catch, what to do with it, and the states a page needs while it waits.
Where a rejection can escape
Three ways to lose an error entirely. All produce silence rather than a message.
A non-awaited async call:
try {
loadOrders();
} catch (error) {
showError(error);
}
The call returned a promise immediately; the rejection comes later with nothing
watching. await is what connects it to your catch.
A .then that starts work without returning it:
getCustomer(1).then((customer) => {
getOrder(customer.id);
}).catch(showError);
The inner promise is not part of the chain, so its rejection bypasses your
.catch — the missing-return trap from the promises lesson, now costing an error
rather than a value.
An async event handler with nothing around it:
button.addEventListener('click', async () => {
const orders = await loadOrders();
render(orders);
});
addEventListener ignores the returned promise. If loadOrders rejects,
nothing catches it.
button.addEventListener('click', async () => {
try {
render(await loadOrders());
} catch (error) {
showError(error);
}
});
Every async event handler needs its own try/catch. This is the most
common place real applications fail silently.
What an unhandled rejection looks like
Worth recognising:
Uncaught (in promise) Error: Could not load orders (500)
In Node it is worse — an unhandled rejection terminates the process.
A last-resort net, for logging rather than handling:
window.addEventListener('unhandledrejection', (event) => {
console.error('Unhandled rejection:', event.reason);
});
Useful in development to catch the ones you missed. Not a substitute for handling them — by the time it fires, you have no idea what the user was doing.
Catch where you can do something
The question is not "should I catch this" but "can I do anything useful here?"
Low-level functions should usually not catch. They should let the error rise to somebody who knows what the user is looking at:
async function getOrders() {
const response = await fetch('/api/orders');
if (!response.ok) {
throw new HttpError(response.status, 'Could not load orders');
}
return response.json();
}
No try/catch. It cannot show a message — it does not know whether it is
feeding a page, a test or a background refresh.
The caller catches, because it owns the screen:
async function showOrders() {
setState('loading');
try {
const orders = await getOrders();
render(orders);
setState('ready');
} catch (error) {
showError(messageFor(error));
setState('error');
}
}
Catch at the boundary between your logic and your interface. Catching in the middle usually means swallowing something and returning a value the caller cannot distinguish from success.
Turning an error into a message
Module 5's typed errors earn their keep here:
function messageFor(error) {
if (error.name === 'AbortError') {
return 'That took too long. Please try again.';
}
if (error instanceof TypeError) {
return 'Could not reach the server. Check your connection.';
}
if (error instanceof HttpError) {
if (error.status === 404) return 'We could not find those orders.';
if (error.status >= 500) return 'The server is having trouble. Try again shortly.';
return 'Something was wrong with that request.';
}
return 'Something went wrong. Please try again.';
}
Four rules for messages that actually help:
Say what to do next. "Check your connection" beats "Network error".
Never show the raw error. error.message is written for programmers and can
leak details of your system. Log it; show something human.
Do not blame the user for a 500.
Always have a fallback. The last return catches the error you did not
anticipate, which is the one that will happen.
Retrying — and when not to
A failed request on mobile data is often worth one retry:
async function withRetry(operation, attempts = 3) {
for (let attempt = 1; attempt <= attempts; attempt++) {
try {
return await operation();
} catch (error) {
const lastAttempt = attempt === attempts;
const worthRetrying = error instanceof TypeError || error.status >= 500;
if (lastAttempt || !worthRetrying) throw error;
await wait(2 ** attempt * 100);
}
}
}
Two things that matter more than the loop.
Only retry what might succeed next time. A network failure or a 500, yes. A 404 or a 400 will fail identically forever — retrying wastes the user's data and delays the message.
Back off. 2 ** attempt * 100 waits 200ms, then 400ms, then 800ms. Retrying
immediately three times is three failures in 30 milliseconds, and if the server
is struggling you have tripled its load.
Never retry a non-idempotent request automatically. Re-sending a POST that
places an order can place it twice — the first may have succeeded and only the
response been lost. GET is safe to repeat; POST needs the user to decide.
The states a page needs
Not strictly error handling, but the same job: it is what the user sees while this is going on.
Every async operation has four states, and most beginner code handles one.
| State | What the user sees |
|---|---|
| Idle | Nothing yet |
| Loading | A spinner or skeleton — immediately |
| Error | A message, and a way to try again |
| Ready | The data — or an empty state |
async function showOrders() {
list.textContent = '';
status.textContent = 'Loading orders…';
try {
const orders = await getOrders();
status.textContent = orders.length === 0 ? 'No orders yet.' : '';
render(orders);
} catch (error) {
console.error(error);
status.textContent = messageFor(error);
}
}
Two details that separate a finished page from a demo.
Empty is not an error. Zero orders is a success with nothing in it, and it
needs its own message — module 2's truthiness trap applies: if (orders.length)
treats an empty list and a failure the same way.
Show the loading state before the request, not inside .then. And leave it
up until the operation finishes — finally is the natural place to take it down:
} finally {
spinner.hidden = true;
}
On a fast connection these flash by. On Slow 3G in a train tunnel they are the entire experience, and the Network panel's throttling is how you find out which one you have built.
Check your work
Three ways a rejection escapes: a non-awaited async call, a .then that
starts work without returning it, and an async event handler with no
try/catch.
An unhandled rejection is Uncaught (in promise) in a browser and terminates
the process in Node. A window.unhandledrejection listener is for logging, not
handling.
Low-level functions should throw; the caller catches, because only the caller knows what the user is looking at. Catch at the boundary between logic and interface.
Map error types to messages that say what to do, with a fallback for the one
you did not predict. Never show error.message to a user.
Retry only what might succeed — network failures and 5xx, not 404 or 400 —
with a backoff, and never automatically retry a POST, which may have
succeeded already.
Four states: idle, loading, error, ready. An empty result is a ready state
with its own message, not an error — and if (orders.length) conflates it with
failure.
Practice
- Lose an error three ways. Write each of the three escapes, confirm nothing is reported, and fix each one.
- Add a
window.unhandledrejectionlistener and confirm it catches what you missed. Then explain why it is not a fix. - Write
getOrders()that throws and ashowOrders()that catches, and convince yourself the split is right. - Write
messageFor(error)handling a timeout, a network failure, a 404, a 500 and an unknown error. Test all five. - Show a raw error message to the user, then read it back as though you were the user. Decide what it told them to do.
- Build the four states with a deliberately slow endpoint: loading, then either error or ready. Then make the endpoint return an empty list and confirm you get a sensible message rather than a blank page.
- Write
if (orders.length)around your render and watch an empty list take the error path. Fix it. - Write
withRetryand test it against an operation that fails twice and then succeeds. Log each attempt so you can see the backoff. - Make it retry a 404 and count how many pointless requests you sent.
- Throttle to Slow 3G in devtools and use your page. Note every moment where you were unsure whether it was working.
- Harder. Take the
fetchJsonfrom the last lesson and build a small order page on it: loading state, typed errors mapped to messages, a Retry button that only appears for retryable failures, and an empty state. Then make the request fail on the second load rather than the first, and check that the previously rendered data is not left on screen underneath an error message — a stale list under a red banner is worse than either alone.
Next: putting it together — a page that loads live data and survives everything the network does to it.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship