← All concepts
Asynchronous JavaScript

Error Handling: try, catch, finally

try/catch lets your code recover from a thrown error instead of crashing, and finally always cleans up.

Definition

A `try` block runs code that might fail. If any statement inside it throws, execution jumps straight to the matching `catch` block, which receives the thrown value (usually an `Error` object) and can log it, recover, or decide to throw again. Code after the point of failure inside `try` never runs, but code inside `catch` runs in its place, so the program keeps going instead of crashing. You can throw your own errors with the `throw` keyword, using a plain value or, better, an `Error` (or a subclass of it) so callers get a `message` and a stack trace. A `finally` block, if present, runs after `try`/`catch` no matter what happened: whether the `try` block finished cleanly, threw and was caught, or even threw again from inside `catch`. This makes it the right place for cleanup that must always happen, like closing a connection or hiding a loading spinner. One sharp edge is that a `return` (or `throw`) inside `finally` overrides whatever the `try` or `catch` block was about to return. With `async`/`await`, a rejected promise behaves like a thrown error, so wrapping an `await` in `try`/`catch` catches it the same way a synchronous throw would.

Examples

function safeParse(json) {
  try {
    return JSON.parse(json);
  } catch (err) {
    console.error("Bad JSON:", err.message);
    return null;
  }
}
safeParse("{}"); // {}
safeParse("not json"); // null, error logged

JSON.parse throws on invalid input; catch stops that error from crashing the caller and returns a safe fallback instead.

function withCleanup() {
  try {
    throw new Error("failed");
  } finally {
    console.log("cleanup runs");
  }
}
// logs "cleanup runs", then the error still propagates

finally runs even though there is no catch here; it does not swallow the error, the error still propagates after finally finishes.

async function loadUser(id) {
  try {
    const res = await fetch(`/users/${id}`);
    if (!res.ok) throw new Error("request failed");
    return await res.json();
  } catch (err) {
    console.error(err.message);
    return null;
  }
}

await rejects like a synchronous throw, so the same try/catch that handles thrown errors also catches a failed fetch or a manually thrown error.

Common mistakes

  • Catching every error the same way and swallowing it silently, which hides real bugs instead of fixing them.
  • Forgetting that a return or throw inside finally overrides any return or throw from try or catch.
  • Awaiting a promise outside of a try/catch (and with no .catch), which leaves a rejection unhandled instead of caught.

Key takeaways

  • try/catch stops a thrown error from crashing the program and lets you handle it in one place.
  • finally always runs, whether the try block succeeded, threw, or was caught, making it the spot for required cleanup.
  • A return or throw inside finally overrides an earlier return or throw from try or catch.
  • Wrapping an await in try/catch catches a rejected promise the same way it catches a synchronous throw.
Check your understanding

function run() { try { return "from try"; } finally { return "from finally"; } } run();

Where you see this

  • Parsing JSON or other user-supplied data without letting a malformed payload crash the app.
  • Wrapping a network request so a failed fetch shows an error message instead of an unhandled rejection.
  • Validating input by throwing a descriptive error and catching it at a boundary to show one friendly message.
  • Guaranteeing cleanup code (closing a file, clearing a timer, hiding a spinner) runs whether or not the operation succeeded.

Practice this

Further reading

Previous
Microtasks vs. Macrotasks
Next
ES Modules