RizTech Academy logo
RizTech Academy
Asynchronous JavaScriptLesson 6 of 725 min

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

  1. Lose an error three ways. Write each of the three escapes, confirm nothing is reported, and fix each one.
  2. Add a window.unhandledrejection listener and confirm it catches what you missed. Then explain why it is not a fix.
  3. Write getOrders() that throws and a showOrders() that catches, and convince yourself the split is right.
  4. Write messageFor(error) handling a timeout, a network failure, a 404, a 500 and an unknown error. Test all five.
  5. 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.
  6. 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.
  7. Write if (orders.length) around your render and watch an empty list take the error path. Fix it.
  8. Write withRetry and test it against an operation that fails twice and then succeeds. Log each attempt so you can see the backoff.
  9. Make it retry a 404 and count how many pointless requests you sent.
  10. Throttle to Slow 3G in devtools and use your page. Note every moment where you were unsure whether it was working.
  11. Harder. Take the fetchJson from 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