JavaScript Try Catch Finally
A try...catch...finally statement is how JavaScript lets you run code that might fail, respond to that failure gracefully, and guarantee that cleanup code runs no matter what happens. Without it, a single thrown error can unwind your entire program and crash a Node.js process, or leave a web page in a broken state. Mastering try/catch/finally is essential for any code that touches the outside world — parsing user input, calling APIs, reading files, working with data you don’t fully control — because all of that can fail at runtime in ways the compiler can’t catch ahead of time.
Overview: How Try, Catch, and Finally Work
JavaScript executes your code one statement at a time on a structure called the call stack. When something goes wrong, JavaScript doesn’t return a special error value the way some languages do — it throws. A throw statement immediately stops normal execution at that exact point and starts searching up the call stack for a handler. If it finds one, execution resumes there. If it never finds one, the program crashes with an unhandled exception.
The try block marks a region of code you suspect might throw. As long as everything inside it completes normally, the catch block is skipped entirely — it costs you nothing at runtime. The moment any statement inside try throws (either directly via throw, or indirectly because a function you called threw), execution jumps straight to catch, skipping the rest of the try block completely, even if there were ten more lines left to run.
You can throw any value in JavaScript — a string, a number, an object — but the overwhelming convention is to throw an Error object (or a subclass of it). That’s because Error instances automatically capture a message, a name, and a stack property containing a snapshot of the call stack at the moment the error was created. Throwing plain strings loses all of that diagnostic information.
The catch block receives the thrown value as its parameter (commonly named error or err). As of ES2019, you can omit that parameter entirely with catch { ... } if you don’t need to inspect the error — useful when you just want to react to “something failed” without caring what.
The finally block is the part beginners underestimate. It runs after the try/catch logic resolves, and it runs unconditionally: whether the try block succeeded, whether an error was thrown and caught, and even whether the try or catch block executed a return, break, or continue. The only way to skip finally is if the entire process terminates (e.g. a hard crash). This makes it the correct place for cleanup work like closing a file handle, releasing a lock, or resetting a loading flag — work that must happen regardless of success or failure.
One crucial limitation: try/catch only catches synchronous errors thrown during the execution of the try block. It does not catch errors thrown later, on a different turn of the event loop — for example inside a setTimeout callback, or inside a Promise’s .then() handler. For asynchronous code, you either use .catch() on the promise chain, or wrap an await expression in a try/catch inside an async function, since await re-throws a rejected promise synchronously into the surrounding try block.
Syntax
try {
// code that might throw
} catch (error) {
// runs only if the try block throws
// "error" holds the thrown value
} finally {
// always runs, whether or not an error occurred
}
- try — wraps the code you want to monitor for exceptions. Required.
- catch (error) — runs only when the try block throws; the parameter holds the thrown value. The parameter is optional (
catch { }) since ES2019. - finally — runs after try/catch resolve, regardless of outcome. Optional, but if present it always executes.
- You must include at least one of
catchorfinallyaftertry—tryalone is a syntax error. - You can nest try/catch blocks, and you can rethrow from inside a catch block with
throw error;if you can’t fully handle it there.
Examples
Example 1: Catching a Thrown Error
function divide(a, b) {
if (b === 0) {
throw new Error('Division by zero is not allowed');
}
return a / b;
}
try {
console.log(divide(10, 2));
console.log(divide(10, 0));
} catch (error) {
console.log('Caught an error:', error.message);
} finally {
console.log('Division attempt finished');
}
Output:
5
Caught an error: Division by zero is not allowed
Division attempt finished
The first call, divide(10, 2), succeeds and logs 5. The second call, divide(10, 0), throws before it ever returns, so console.log never receives a value for that line — control jumps immediately to catch, which reports the error’s message. Notice that finally still runs afterward, printing its cleanup message even though an error occurred.
Example 2: Finally Always Runs, Even With a Return
function readValue(useFallback) {
try {
if (!useFallback) {
throw new Error('Primary source unavailable');
}
return 'primary-value';
} catch (error) {
console.log('Error caught:', error.message);
return 'fallback-value';
} finally {
console.log('Cleanup: closing connection');
}
}
console.log('Result:', readValue(false));
console.log('Result:', readValue(true));
Output:
Error caught: Primary source unavailable
Cleanup: closing connection
Result: fallback-value
Cleanup: closing connection
Result: primary-value
This example proves that finally runs even when a return statement has already fired inside try or catch. JavaScript holds the pending return value, runs finally to completion, and only then actually returns to the caller. That’s true for both the error path (useFallback = false) and the success path (useFallback = true) — the cleanup message prints before the result in both cases.
Example 3: Custom Error Classes and Selective Handling
class ValidationError extends Error {
constructor(message) {
super(message);
this.name = 'ValidationError';
}
}
function validateAge(age) {
if (typeof age !== 'number' || Number.isNaN(age)) {
throw new ValidationError('Age must be a number');
}
if (age < 0 || age > 130) {
throw new ValidationError('Age must be between 0 and 130');
}
return true;
}
function processUser(user) {
try {
validateAge(user.age);
console.log(`${user.name} is valid`);
} catch (error) {
if (error instanceof ValidationError) {
console.log(`Validation failed for ${user.name}: ${error.message}`);
} else {
throw error;
}
}
}
const users = [
{ name: 'Ava', age: 29 },
{ name: 'Ben', age: -5 },
{ name: 'Cy', age: 'old' }
];
for (const user of users) {
processUser(user);
}
Output:
Ava is valid
Validation failed for Ben: Age must be between 0 and 130
Validation failed for Cy: Age must be a number
This example shows two important patterns together: creating a custom error type by extending Error, and using instanceof inside catch to decide whether you actually know how to handle the error. If processUser caught some completely unrelated error (say, a typo causing a TypeError), the else branch rethrows it with throw error; instead of silently misreporting it as a validation problem.
How It Works Step by Step (Under the Hood)
Internally, the JavaScript engine tracks every block’s outcome as a completion record — essentially a tag saying “this block finished normally,” “this block returned a value,” “this block threw,” or “this block broke/continued out of a loop.” Here’s what happens when a try/catch/finally statement executes:
- The engine begins executing statements inside
tryone by one, exactly as it would outside a try block. - If every statement finishes without throwing, the try block’s completion is “normal,” the
catchblock is skipped entirely, and the engine moves on tofinally. - If a statement throws, the engine immediately abandons the rest of the try block — no further lines in it execute — and captures a “throw” completion carrying the thrown value.
- If a
catchblock exists, the engine binds the thrown value to the catch parameter and runs the catch block. Whatever that block does (finishes normally, returns, or throws again) becomes the new completion record. - Regardless of which path was taken, the engine now runs
finally, if present. This is unconditional — even a pending exception or a pending return value is temporarily set aside while finally executes. - Finally, the engine resolves the outcome: if
finallyitself produced a new completion (its ownreturn,throw,break, orcontinue), that new completion overrides whatever was pending before — including discarding an in-flight exception. Otherwise, the previously pending completion (the return value or the uncaught throw) is allowed to proceed, propagating further up the call stack if nothing caught it.
That override behavior is exactly what happens in the “finally overrides everything” mistake shown below, and it’s the single most misunderstood detail of this statement.
Common Mistakes
Mistake 1: Swallowing Errors With an Empty Catch Block
function saveSettings(settings) {
try {
const serialized = JSON.stringify(settings);
store.write(serialized);
} catch (error) {
// nothing here - the failure vanishes silently
}
console.log('Settings saved');
}
saveSettings({ theme: 'dark' });
Output:
Settings saved
Here store was never defined, so store.write(...) throws a ReferenceError. The empty catch block swallows it completely, and the very next line still prints "Settings saved" — a confident lie. Empty catch blocks are one of the most dangerous patterns in JavaScript because they hide real bugs behind code that looks like it’s working.
Corrected version — always do something with the error, even if it’s just logging it:
function saveSettingsSafe(settings) {
try {
const serialized = JSON.stringify(settings);
store.write(serialized);
console.log('Settings saved');
} catch (error) {
console.error('Failed to save settings:', error.message);
}
}
saveSettingsSafe({ theme: 'dark' });
Output:
Failed to save settings: store is not defined
Mistake 2: Using Return Inside Finally
function getStatus() {
try {
throw new Error('Connection lost');
} finally {
return 'ok';
}
}
console.log(getStatus());
Output:
ok
This looks harmless but is a serious bug: the Error('Connection lost') is thrown, but before it can propagate anywhere, finally executes its own return 'ok'. As explained above, a completion produced inside finally always overrides a pending one, so the exception is discarded entirely and the caller sees a cheerful "ok" with no idea anything went wrong.
Corrected version — keep finally free of return/break/continue, and let catch handle the outcome:
function getStatusSafe() {
try {
throw new Error('Connection lost');
} catch (error) {
console.error('Status check failed:', error.message);
return 'error';
} finally {
console.log('Status check complete');
}
}
console.log(getStatusSafe());
Output:
Status check failed: Connection lost
Status check complete
error
Best Practices
- Only wrap the specific lines that can actually throw in
try— wrapping an entire function “just in case” makes it harder to tell what error you’re really handling. - Always throw
Errorobjects (or subclasses), never plain strings or numbers, so you get amessage,name, andstacktrace for free. - Never leave a
catchblock empty. At minimum, log the error; ideally, decide whether to recover, rethrow, or show the user something meaningful. - Never put
return,break, orcontinueinside afinallyblock — it silently discards any pending error or return value. - Use
finallystrictly for cleanup: closing connections, releasing locks, resetting loading/spinner state — not for business logic. - Create custom error subclasses (like
ValidationErrorabove) for different failure categories, and useinstanceofincatchto handle only the errors you understand, rethrowing the rest. - For asynchronous code, wrap
awaitexpressions intry/catchinsideasyncfunctions — a plain synchronoustry/catchwill not catch a promise rejection from an unrelated.then()chain orsetTimeout. - Don’t use try/catch for routine control flow (like checking if a key exists in an object) — it’s meant for exceptional, unpredictable failures, and overusing it makes code harder to follow.
Practice Exercises
- Exercise 1: Write a function
safeJsonParse(text)that tries toJSON.parse(text)inside a try block. If parsing fails, catch the error and returnnullinstead of letting the program crash. Test it with both valid JSON (like'{"a":1}') and invalid JSON (like'not json'). - Exercise 2: Write a function
withRetry(fn, attempts)that callsfn()inside a try/catch. If it throws, log the failure and try again, up toattemptstimes, finally rethrowing the last error if every attempt fails. Use afinallyblock to log how many attempts were made. - Exercise 3: Create a custom error class
InsufficientFundsErrorextendingError. Write a functionwithdraw(balance, amount)that throws this error whenamount > balance. Call it inside a try/catch that distinguishes this specific error (viainstanceof) from any other unexpected error.
Summary
trymarks code that might throw; if it throws, execution jumps immediately tocatch, skipping the rest of the try block.catch (error)receives the thrown value and handles it; the parameter can be omitted entirely since ES2019.finallyalways runs after try/catch resolve — on success, on a caught error, and even acrossreturnstatements — making it the right place for cleanup code.- A
return,throw,break, orcontinueinsidefinallyoverrides any pending completion from try/catch, including silently discarding an in-flight exception — avoid this. try/catchonly catches synchronous errors; asynchronous failures needawaitinside anasyncfunction’s try block, or a.catch()on the promise chain.- Throw
Errorobjects (or custom subclasses), never bare strings, and never leave acatchblock empty.
