Error Handling
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.
Learn Error Handling in our free Node.js course — a beginner-friendly interactive lesson with worked examples, a practice exercise and a quick reference.
Part of the free Node.js course at LearnCodingFast — hands-on lessons with examples you run in your browser, 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
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.
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.
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.
3️⃣ 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.
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.
Common 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)
Frequently Asked Questions
🎉 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
- ✅ Next lesson: Express Intro & Routing
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.