try / catch / finally
Runtime errors are inevitable: a JSON body is malformed, a property is read off undefined, a number won’t parse. Left unhandled, a thrown error unwinds the call stack and crashes the current task — in Node.js it can take down the whole process. The try/catch/finally statement lets you wrap risky code, intercept the error, and run cleanup unconditionally, turning an abrupt crash into a controlled recovery. Knowing exactly what it does and does not catch is what separates robust code from code that fails silently.
How try / catch / finally works
A try block holds the code that might throw. If anything inside it throws, execution jumps immediately to the catch block, which receives the thrown value. The optional finally block runs afterward no matter what — whether the try finished cleanly, an error was caught, or even if the function returned or re-threw.
function parseConfig(raw) {
try {
const config = JSON.parse(raw);
return config.timeout;
} catch (err) {
console.warn("Invalid config, using default:", err.message);
return 5000;
} finally {
console.log("Parse attempt complete");
}
}
console.log(parseConfig('{"timeout": 1000}'));
console.log(parseConfig("not json"));
Output:
Parse attempt complete
1000
Invalid config, using default: Unexpected token 'o', "not json" is not valid JSON
Parse attempt complete
5000
You must pair try with at least one of catch or finally — a lone try is a syntax error. The valid forms are try/catch, try/finally, and try/catch/finally.
What catch receives
The binding in catch (err) is the value that was thrown. By convention that’s an Error instance, which carries a useful message, name, and stack. But JavaScript lets you throw any value — a string, a number, an object — so defensive code should not blindly assume an Error.
try {
throw new TypeError("expected a string");
} catch (err) {
console.log(err.name); // "TypeError"
console.log(err.message); // "expected a string"
console.log(err instanceof Error); // true
}
Always throw
Error(or a subclass), never a bare string. OnlyErrorobjects capture a stack trace, and downstream code that readserr.messagewill break on a thrown string.
When you can’t trust the source, narrow before using the value:
catch (err) {
const message = err instanceof Error ? err.message : String(err);
log(message);
}
Optional catch binding
If you don’t need the error value — for instance when you simply want to fall back to a default — you can omit the binding entirely. This optional catch binding has been supported since ES2019.
function isValidJSON(text) {
try {
JSON.parse(text);
return true;
} catch {
return false;
}
}
Omitting (err) makes the intent explicit: the failure itself is the signal, and the details don’t matter here.
finally for cleanup
finally is for work that must happen regardless of outcome — releasing a lock, closing a file handle, hiding a loading spinner, re-enabling a button. Because it runs on both the success and failure paths, you write the cleanup once instead of duplicating it in try and catch.
function withConnection(run) {
const conn = openConnection();
try {
return run(conn);
} finally {
conn.close(); // runs even if run() throws or returns early
}
}
Note there’s no catch here: the error still propagates to the caller, but the connection closes first. One sharp edge — a return (or throw) inside finally overrides whatever try/catch was about to produce, silently discarding the real result or error.
function trap() {
try {
return "from try";
} finally {
return "from finally"; // wins — masks the try value
}
}
console.log(trap());
Output:
from finally
Never
returnorthrowfrom afinallyblock. Keep it limited to side-effecting cleanup so it can’t swallow a real result or error.
What try / catch does not catch
This is the most common source of confusion. A try/catch only catches errors thrown synchronously within its own block, during the current run of the event loop.
| Situation | Caught by surrounding try/catch? |
|---|---|
Synchronous throw in the try block | Yes |
| Error inside a synchronous function it calls | Yes |
Rejected promise you await inside try | Yes |
Rejected promise without await | No |
Error thrown later in setTimeout / event callback | No |
| Syntax / parse error in the script | No (fails before any code runs) |
A callback that throws later runs on a fresh stack, so the original try is long gone:
try {
setTimeout(() => {
throw new Error("too late"); // NOT caught below
}, 0);
} catch (err) {
console.log("never reached");
}
The fix is to put the try/catch inside the callback. For promises, you only catch a rejection if you await it inside the try (or attach .catch()); a dropped promise leaks as an unhandled rejection. Syntax errors are different again — they’re thrown at parse time, before execution begins, so no runtime try/catch can intercept a malformed script.
Best Practices
- Only wrap the code that can actually throw; an oversized
tryblock hides which line failed. - Throw and expect
Errorinstances, but guard withinstanceof Errorbefore reading.messageon untrusted values. - Use optional catch binding (
catch { }) when the error details are irrelevant. - Put unconditional cleanup in
finally, and neverreturnorthrowfrom it. - Don’t swallow errors with an empty
catch— at minimum log them, or re-throw what you can’t handle locally. - Remember
try/catchis synchronous: useawaitinside the block (or.catch()) to handle promise rejections, and move handlers inside deferred callbacks.