Error Handling
Reviewed & published by Brayan K
Error handling is how a Node.js program detects, reports, and recovers from things going wrong — using try/catch for synchronous and async/await code, error-first callbacks, and the Error object to carry details.
Part of the free Node.js course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
By the end of this lesson you'll know how to throw and catch errors, how to handle failures in promises and callbacks (and the trap where try/catch silently misses them), and how to define your own error types and add a process-level safety net so an unexpected failure doesn't take your whole app down without a trace.
What You'll Learn in This Lesson
- How throw, try, catch, and finally work together
- Reading err.message and err.stack from the Error object
- Catching async failures with try/catch around await
- Handling rejected promises with .catch() and error-first callbacks
- Why a throw inside an async callback escapes an outer try/catch
- Custom error classes and process-level safety nets
1️⃣ try / catch / finally and throwing
When something goes wrong you throw an Error object. Throwing immediately stops the current function and jumps to the nearest catch. You wrap risky code in a try block, handle the failure in catch (err), and put cleanup that must always run in finally.
- • throw new Error('msg') — raises an error carrying a message (and a stack trace).
- • err.message — the human-readable text you passed to new Error().
- • err.stack — the call stack with file names and line numbers, for debugging.
- • finally { } — runs whether the try succeeded or threw, so it's perfect for closing files or releasing resources.
Read the worked example below — every line is explained — then run it and compare with the expected output.
// throw creates and "raises" an error. Execution jumps straight
// to the nearest catch block, skipping the rest of the try.
function parseAge(input) {
const age = Number(input);
if (Number.isNaN(age)) {
// new Error(message) builds an Error object carrying details.
throw new Error('Age must be a number');
}
return age;
}
try {
// If parseAge throws, control jumps to catch immediately.
const age = parseAge('not-a-number');
console.log('Age is', age); // never runs here
} catch (err) {
// err is the Error object you threw.
console.log('Caught:', err.message); // -> Caught: Age must be a number
// err.stack shows where it happened (file + line numbers).
// console.log(err.stack);
} finally {
// finally ALWAYS runs — success or failure — great for cleanup.
console.log('Done validating'); // -> Done validating
}2️⃣ Handling Errors in Async Code
Asynchronous code fails in a few different shapes, and the way you catch it depends on the shape. The big trap for beginners: a plain try/catch only catches what happens while it is running, so an error thrown later inside a callback slips right past it.
- • async/await — wrap the await in try/catch; a rejected promise is re-thrown at the await line.
- • promises — attach .catch(err => ...) to handle a rejection without async/await.
- • error-first callbacks — check the first argument first: if (err) return ....
- • the trap — a throw inside a setTimeout or event callback is not caught by an outer try/catch, because the callback runs after the try has already exited.
Read the comments carefully — example 4 shows exactly why the outer catch never fires.
import { readFile } from 'node:fs/promises';
import { readFile as readFileCb } from 'node:fs';
// The three shapes below are AWAITED one at a time on purpose. Started
// together they finish in whatever order the operating system hands the
// results back — run that version four times and you get four different
// orderings, which is true of your code too and worth knowing.
// 1) try/catch DOES work around await — await re-throws the
// rejected promise, and catch picks it up.
async function loadConfig() {
try {
const text = await readFile('missing.json', 'utf8');
return JSON.parse(text);
} catch (err) {
console.log('Async caught:', err.code); // -> Async caught: ENOENT
return {};
}
}
await loadConfig();
// 2) .catch() handles a rejected promise without async/await.
await readFile('missing.json', 'utf8')
.then((text) => console.log(text.length))
.catch((err) => console.log('then/catch:', err.code)); // -> then/catch: ENOENT
// 3) Error-first callbacks: the FIRST argument is the error.
// Check it before using the result. A callback has no promise to await,
// so wrap it in one when you need to wait for it.
await new Promise((done) => {
readFileCb('missing.json', 'utf8', (err, data) => {
if (err) console.log('callback err:', err.code); // -> callback err: ENOENT
else console.log(data);
done();
});
});
// 4) A throw INSIDE an async callback is NOT caught by an outer try/catch —
// the callback runs later, after try has already exited, so there is no
// try block on the stack any more. Wrapping it changes nothing:
//
// try { setTimeout(() => { throw new Error('boom'); }, 0); }
// catch (err) { console.log('never runs:', err.message); }
//
// Normally that throw ends the process. The last-resort net is
// uncaughtException, which is the only reason this block can finish and
// show you a result. In a real service you log there and then EXIT: the
// process is in an unknown state, and carrying on is how one bad request
// turns into corrupted data.
process.on('uncaughtException', (err) => {
console.log('uncaughtException saw:', err.message);
});
setTimeout(() => { throw new Error('boom'); }, 0);Your turn. The program below works once you fill in the three blanks marked ___. Follow the 👉 hints, then run it and compare with the expected output.
// 🎯 YOUR TURN — wrap the risky call so a failure can't crash
// the program. Replace each ___ then run it.
function divide(a, b) {
if (b === 0) throw new Error('Cannot divide by zero');
return a / b;
}
___ { // 👉 the keyword that starts a protected block
const result = divide(10, 0); // this throws because b is 0
console.log('Result:', result); // skipped when it throws
} ___ (err) { // 👉 the keyword that handles the error, with (err)
// 👉 log the human-readable message from the Error object
console.log('Error:', ___); // 👉 the property holding the text
}
// ✅ Expected output:
// Error: Cannot divide by zero3️⃣ Custom Errors & Process-Level Safety Nets
For bigger programs you'll want to tell error types apart. A custom error class extends the built-in Error, so you can check err instanceof ValidationError and attach extra fields like which input failed. And for the failures that escape every handler, Node gives you two last-resort process events.
- • class ValidationError extends Error — a named error type you can recognise and react to specifically.
- • process.on('uncaughtException', fn) — fires when a synchronous error escapes every try/catch.
- • process.on('unhandledRejection', fn) — fires when a rejected promise has no .catch().
Treat the process handlers as smoke detectors: log the failure, shut down gracefully, and let a process manager restart you. They are a safety net, not your everyday error handling.
// A custom error class extends the built-in Error so you can
// recognise specific failures with instanceof and attach data.
class ValidationError extends Error {
constructor(message, field) {
super(message); // sets err.message
this.name = 'ValidationError';
this.field = field; // extra context for the caller
}
}
function createUser(user) {
if (!user.email) {
throw new ValidationError('Email is required', 'email');
}
return user;
}
try {
createUser({});
} catch (err) {
if (err instanceof ValidationError) {
console.log(`${err.name} on ${err.field}: ${err.message}`);
// -> ValidationError on email: Email is required
} else {
throw err; // re-throw anything we didn't expect
}
}
// LAST-RESORT safety nets. These catch errors that escaped every
// other handler. Log, then exit — the process is in an unknown state.
process.on('uncaughtException', (err) => {
console.error('Uncaught:', err.message);
process.exit(1);
});
process.on('unhandledRejection', (reason) => {
console.error('Unhandled rejection:', reason);
process.exit(1);
});Mini-Challenge: A Safe JSON Parser
No blanks this time — just a brief and an outline to keep you on track. Write it yourself, run it, and check your output against the example in the comments. Parsing untrusted input is exactly where real apps crash, so this is a habit worth building.
// 🎯 MINI-CHALLENGE: a safe JSON parser
// 1. Write parseJsonSafe(text) that returns the parsed object.
// 2. Wrap JSON.parse in try/catch (it throws on bad input).
// 3. On failure, log err.message and return null instead of crashing.
// 4. Call it once with valid JSON and once with broken JSON.
//
// ✅ Example output:
// { ok: true }
// Parse failed: Unexpected token o in JSON at position 1
// null
// your code hereCommon Errors (and the fix)
- Swallowing errors silently — an empty catch (err) { } hides the failure and makes bugs impossible to trace. At minimum, log err.message; usually you should also handle or re-throw it.
- Throwing non-Error values — throw 'oops' loses the stack trace and breaks code that reads err.message. Always throw new Error(...) (or a subclass).
- Forgetting to handle promise rejections — a promise with no .catch() (or no try/catch around its await) triggers an unhandledRejection and can crash the process.
- Assuming try/catch catches async callbacks — a throw inside setTimeout or an event handler runs after the try has exited, so the outer catch never sees it. Handle the error inside the callback.
- Not re-throwing or logging — if a catch block can't actually recover, don't bury the error. Log it, or re-throw with throw err so a caller (or your safety net) can deal with it.
Pro Tips
- 💡 Prefer async/await with try/catch over long .then().catch() chains — the error flow reads top-to-bottom like synchronous code.
- 💡 Add context when you re-throw: wrap a low-level error in a clearer one (and set the new error's cause) so logs explain what you were doing, not just what broke.
- 💡 Use custom error classes so callers can branch on instanceof instead of matching fragile message strings.
📋 Quick Reference — Error Handling
| Concept | Node.js Syntax |
|---|---|
| Throw an error | throw new Error(msg) |
| Catch an error | try { } catch (err) { } |
| Read the message | err.message |
| Always-run cleanup | finally { } |
| Custom error type | class X extends Error |
| Async safety net | process.on('unhandledRejection', fn) |
🎉 Lesson Complete!
- ✅ throw new Error('msg') raises a failure; try { } catch (err) { } handles it
- ✅ err.message and err.stack carry the details you need to debug
- ✅ finally { } always runs — perfect for cleanup
- ✅ Wrap await in try/catch; use .catch() for promises and if (err) for callbacks
- ✅ A throw inside an async callback escapes an outer try/catch — handle it where it happens
- ✅ Custom errors (extends Error) and process.on('unhandledRejection') are your last lines of defense
Practice quiz
When you throw inside a try block, where does execution jump?
- To the nearest catch block, skipping the rest of the try
- To the end of the file
- Back to the start of the try
- To the finally block only, never catch
Answer: To the nearest catch block, skipping the rest of the try. throw immediately stops the current function and jumps to the nearest catch.
When does a finally block run?
- Only when the try succeeds
- Only when the try throws
- Always, whether the try succeeded or threw
- Only if there is no catch
Answer: Always, whether the try succeeded or threw. finally always runs after try/catch, making it ideal for cleanup like closing files.
Does a plain try/catch catch an error thrown inside a setTimeout callback?
- Yes, always
- No, because the callback runs after the try has already exited
- Only if the delay is 0
- Only with await
Answer: No, because the callback runs after the try has already exited. The callback runs later, after the try block finished, so the outer catch never sees the throw.
How do you catch a rejected promise when using async/await?
- Attach .then() only
- Wrap the await in a try/catch block
- Use process.exit()
- Ignore it; await never rejects
Answer: Wrap the await in a try/catch block. await re-throws a rejected promise at that line, so a surrounding try/catch catches it.
In an error-first callback (err, result), what should you do first?
- Use result before checking err
- Throw the result
- Log result and return
- Check err and return early if it is set
Answer: Check err and return early if it is set. Check the first argument first: if (err) return handleIt(err), only then use the result.
Why should you throw an Error object rather than a string?
- Strings are slower to throw
- Error objects carry a message and a stack trace that tools expect
- You cannot throw a string in Node
- Strings always crash the process
Answer: Error objects carry a message and a stack trace that tools expect. Error objects carry message and stack; a thrown string loses the stack and breaks err.message readers.
How does a custom error class let callers recognize a specific failure?
- By matching the message text exactly
- By reading a global variable
- By checking err instanceof ValidationError
- By comparing line numbers
Answer: By checking err instanceof ValidationError. Extending Error lets callers branch on instanceof instead of fragile message-string matching.
What fires when a rejected promise has no .catch() and no surrounding try/catch?
- process.on('unhandledRejection')
- process.on('uncaughtException')
- process.on('exit')
- process.on('SIGTERM')
Answer: process.on('unhandledRejection'). unhandledRejection fires for a rejected promise nobody handled; uncaughtException is for sync escapes.
What is the right way to use uncaughtException and unhandledRejection handlers?
- As your everyday error handling for all logic
- To silently ignore every error
- Only to log the failure and shut down, since the process is in an unknown state
- To retry the failed operation forever
Answer: Only to log the failure and shut down, since the process is in an unknown state. They are last-resort safety nets: log, shut down gracefully, and let the process restart.
What is wrong with an empty catch block like catch (err) {}?
- It is a syntax error
- It re-throws automatically
- It speeds up the program
- It swallows the failure silently and makes bugs impossible to trace
Answer: It swallows the failure silently and makes bugs impossible to trace. Swallowing errors hides failures; at minimum log err.message, and usually handle or re-throw.
Continue this course
- Previous: Async Patterns
- Next: Express Intro & Routing — Build web apps faster with the most popular Node framework
- Quick reference: Node.js cheat sheet
Frequently asked questions
Does a normal try/catch catch errors from async code in Node.js?
It depends on how the async code is written. A try/catch DOES catch errors when you await a promise inside the try block, because await re-throws a rejected promise at that line. But a try/catch does NOT catch an error thrown later inside a callback (like setTimeout or an event handler), because that callback runs after the try block has already finished. For callbacks, handle the error inside the callback itself; for promises, prefer async/await with try/catch or attach a .catch().
What is an error-first callback and why is the error the first argument?
An error-first callback is the classic Node.js convention where asynchronous functions call back with (err, result). The error comes first so you are reminded to check it before touching the result: if (err) return handleIt(err). If err is null or undefined the operation succeeded. Many older core APIs (like fs.readFile) use this pattern; the promise-based versions in node:fs/promises let you use try/catch instead.
Should I throw strings or throw Error objects?
Always throw an Error object (or a subclass of it), never a plain string or number. Error objects carry a message and a stack trace that shows exactly where the failure happened, and tools and frameworks expect that shape. If you throw a string you lose the stack trace and break code that reads err.message or checks err instanceof SomeError.
What are uncaughtException and unhandledRejection for?
They are process-level last-resort handlers. process.on('uncaughtException') fires when a synchronous error escapes every try/catch, and process.on('unhandledRejection') fires when a rejected promise has no .catch(). Use them only to log the error and shut down gracefully, then let the process exit and restart — after an uncaught error the program is in an unknown state and is not safe to keep running. They are a safety net, not a substitute for handling errors where they happen.