2HAASStart a project
All posts

Tech Tips

Async error handling in JavaScript: try/catch done right

Unhandled promise rejections crash Node.js services and leave browser apps quietly broken. Catch async errors with try/catch, .catch and a global safety net.

The 2HAAS team2 min read

Asynchronous code fails differently from synchronous code. An error thrown inside a promise does not travel up the call stack. It becomes a rejected promise, and if nothing handles that rejection, a browser app keeps running in a broken state while a Node.js process exits.

Most of these failures come from three small habits. Fix them and async code becomes predictable.

Why try/catch sometimes misses the error

try/catch only sees a rejection when the promise is awaited inside the try block.

async function loadUser(id) {
  try {
    fetchUser(id); // not awaited, so a rejection escapes the try block
  } catch (err) {
    console.error(err); // never runs
  }
}

Add await and the same catch block starts working. The same rule applies to return fetchUser(id): write return await fetchUser(id) when you want the surrounding try to handle the failure.

The pattern for async/await

async function loadUser(id) {
  try {
    const res = await fetch(`/api/users/${id}`);
    if (!res.ok) throw new Error(`Request failed with status ${res.status}`);
    return await res.json();
  } catch (err) {
    logger.error({ err, id }, "Could not load user");
    throw err; // let the caller decide what the user sees
  }
}

Two details matter here:

  • fetch only rejects on a network failure. A 404 or a 500 is a normal response, so check res.ok yourself.
  • Catching an error is not the same as handling it. Either recover with a sensible fallback or rethrow. Swallowing the error hides the bug.

Promise chains end with .catch

When you do not await a promise, finish the chain with .catch.

saveDraft(draft).then(showSaved).catch(showSaveError);

This is the fix for fire-and-forget calls such as analytics events or background saves.

Add a global safety net

A last line of defence tells you about the rejections you missed.

// Browser
window.addEventListener("unhandledrejection", (event) => {
  reportError(event.reason);
});

// Node.js
process.on("unhandledRejection", (reason) => {
  logger.fatal({ reason }, "Unhandled rejection");
  process.exit(1);
});

Treat these as alarms. If they fire, there is a missing try/catch or .catch somewhere, and that is the real fix.

A short checklist

  1. Every await that can fail sits inside a try block, or the caller handles it.
  2. Every promise you do not await ends in .catch.
  3. HTTP responses are checked with res.ok.
  4. Errors are logged once, with context, in one place.
  5. The user sees what happened and what to do next.

When several requests run together and some may fail, Promise.allSettled keeps the successful results instead of throwing them away.

Keep reading

  1. Code Snippets2 min read

    Promise.allSettled: keep the results that succeeded

    Promise.all throws away every result when one request fails. Promise.allSettled waits for all of them and reports each outcome, so one failure cannot empty a page.

  2. Tech Tips2 min read

    Dependency hygiene: audit, update and prune npm packages

    Outdated and unused packages are a security risk and a tax on every install. A short routine with npm audit, npm outdated and a cleanup tool keeps a project healthy.

  3. Tech Tips2 min read

    Tree shaking: why your bundle still ships unused code

    Tree shaking removes code you never import, but only when the bundler can prove it is unused. ES modules, the sideEffects flag and your import style decide what goes.

Tell us what’s in the way.

A few lines is enough. You get a candid yes, a candid no, or a sharper question.

  • Working software in week four
  • A reply in one business day
  • Handover is the deliverable

Prefer email? [email protected]

The problem, who it is for, and what is in the way right now.

One business day to a real reply. No sales sequence, no call required.