Skip to main content

Error handling: try/catch/finally in JavaScript

try/catch/finally is a JavaScript construct that lets code attempt a risky operation, handle any thrown error, and run cleanup code no matter what happens.

Theory

TL;DR

  • Analogy: surgeon with a backup plan. try is the operation, catch handles complications, finally closes the patient up either way
  • finally always runs, even if catch re-throws or return exits early
  • Wrap API calls, JSON.parse, file reads: anything that can fail at runtime
  • Never use try/catch for control flow. That is what if/else is for

Quick example

javascript
try { const data = JSON.parse('{ invalid }'); // Throws SyntaxError console.log(data); // Skipped } catch (error) { console.log('Caught:', error.message); // "Unexpected token..." } finally { console.log('Cleanup done'); // Always prints } // Output: // Caught: Unexpected token i in JSON at position 2 // Cleanup done

When JSON.parse throws, execution jumps straight to catch. Code after the throw inside try is skipped. Then finally runs.

What finally actually does

finally runs after try/catch completes, no matter the outcome: success, caught error, or re-thrown error. Without it, you would need to duplicate cleanup code in both the try path and the catch path. One block covers both.

This matters most for resource cleanup: closing database connections, stopping loading spinners, releasing file handles. If catch returns early, finally still fires before the function actually returns.

When to use

  • Fetch might fail due to network issues or bad status codes: wrap in try/catch, show a user-friendly message
  • JSON.parse on user input: catch SyntaxError, fall back to default data
  • File reads in Node.js: finally closes the stream even if reading fails
  • async/await functions: try/catch is the clean way to handle rejected promises
  • Skip it for predictable paths. Use if/else to check conditions you control

How V8 handles this

During compilation, V8 wraps the try block in an exception handler and builds an internal exception table. When a throw happens, the engine unwinds the call stack and jumps to catch. After catch runs, finally executes in the same lexical scope as try. In browsers, unhandled errors also fire window.onerror. In Node.js, they emit uncaughtException.

Common mistakes

1. Assuming finally skips on return

javascript
function test() { try { return 'success'; } finally { console.log('This runs before return'); } } console.log(test()); // "This runs before return" // "success"

finally fires before the actual return. But if finally has its own return, it overrides the value from try. Design finally for side effects only, not return values.

2. Catching everything silently

javascript
// Bad: swallows real bugs try { JSON.parse('bad'); } catch (e) {} // Better: check the type try { JSON.parse('bad'); } catch (error) { if (error instanceof SyntaxError) { console.warn('Invalid JSON, using defaults'); } else { throw error; // Re-throw anything unexpected } }

A TypeError caused by a coding mistake looks the same as a SyntaxError from bad input. Catch-all blocks hide both equally well.

3. Forgetting finally in Node.js resource cleanup

javascript
// Risk: stream left open after error const stream = fs.createReadStream('file.txt'); try { // read data } catch (error) { console.error(error); } // Correct try { // read data } catch (error) { console.error(error); } finally { stream.destroy(); // Runs no matter what }

The most common issue I have seen in Node.js codebases is this exact pattern in DB route handlers. One failed query leaves a connection open. Enough of those and the pool exhausts completely.

4. Using try/catch for control flow

javascript
// Slow and misleading try { riskyOperation(); } catch { fallback(); } // If you can check first, do it if (canRun()) { riskyOperation(); } else { fallback(); }

Exceptions carry overhead in V8. Use them for actual unexpected failures, not expected branching.

Real-world usage

  • Express.js: async route handlers wrapped in try/catch, unknown errors passed to next(error) for global middleware
  • React: useEffect with fetch wrapped in try/catch, AbortController cleanup in finally
  • Node.js fs/promises: readFile with finally to close streams
  • Axios interceptors: try/catch inside request and response transformers for validation

Follow-up questions

Q: What happens if finally has a return statement?
A: It overrides the return value from try or catch. The earlier return is discarded. This trips up a lot of utility function authors.

Q: Does try/catch catch errors inside async callbacks like setTimeout?
A: No. A try/catch around setTimeout(() => { throw new Error() }) will not catch the error because the callback runs in a different execution context. Put try/catch inside the callback, or switch to async/await.

Q: Can you write try/finally without catch?
A: Yes. The error still propagates up the stack, but finally runs first. Useful when you want cleanup without handling the error at this level.

Q: What is the difference between .catch() on a Promise and try/catch in an async function?
A: Both catch rejected promises. try/catch in async functions is often cleaner for sequential async code. .catch() fits better for inline handling in promise chains.

Q: (Senior) How does re-throwing interact with finally?
A: Re-throwing in catch does not skip finally. Stack unwinding only completes after finally finishes. So finally always runs, then the re-thrown error propagates to the outer scope.

Examples

Parsing user input with fallback

javascript
function parseConfig(input) { try { const config = JSON.parse(input); // Throws SyntaxError on bad input return config; } catch (error) { if (error instanceof SyntaxError) { console.warn('Bad config, using defaults'); return { theme: 'light', lang: 'en' }; // Fallback value } throw error; // Re-throw non-syntax errors } } console.log(parseConfig('{ "theme": "dark" }')); // { theme: "dark" } console.log(parseConfig('not json')); // { theme: "light", lang: "en" }

The function handles SyntaxError specifically and returns a fallback. Anything else gets re-thrown so bugs do not vanish silently.

Express route with database connection cleanup

javascript
app.get('/user/:id', async (req, res, next) => { try { const user = await db.getUser(req.params.id); // Throws on invalid ID res.json(user); } catch (error) { if (error.name === 'NotFoundError') { res.status(404).json({ error: 'User not found' }); } else { next(error); // Pass to Express global error handler } } finally { await db.releaseConnection(); // Always frees the connection } });

Even if db.getUser throws or next(error) is called, the connection gets released. Without finally, a failed request leaks a connection from the pool.

Re-throw behavior with async/await

javascript
async function risky() { try { throw new Error('Boom'); } catch (error) { console.log('Caught:', error.message); // "Boom" throw error; // Re-throw } finally { console.log('Finally runs'); // Runs even on re-throw } } risky().catch(e => console.log('Outer:', e.message)); // Output: // Caught: Boom // Finally runs // Outer: Boom

Many developers expect re-throwing to skip finally. It does not. finally always completes before the error continues propagating outward.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?