Chapter 12: Node.js Error Handling
Error Handling in Node.js — written as if I’m sitting next to you, showing code on the screen, explaining decisions, common traps, production patterns, and real-world trade-offs.
Let’s go step by step — from basic mistakes to professional-level patterns used in 2025–2026.
1. Why error handling in Node.js is surprisingly tricky
Node.js has two completely different worlds of errors:
| World | Type of code | How errors are reported | Must catch them? |
|---|---|---|---|
| Synchronous code | Normal function calls, loops, calculations | Thrown exceptions (throw new Error()) | Yes — try/catch |
| Asynchronous code | callbacks, promises, async/await | Usually not thrown — passed or rejected | Yes — special care |
Most beginners only learn how to catch synchronous errors and then get surprised when:
- the server crashes on unhandled promise rejections
- async route handlers silently fail
- errors disappear in .then() chains
Goal of good Node.js error handling:
- Never crash the process in production (unless catastrophic)
- Always log meaningful information
- Always respond to the client with proper HTTP status + message
- Never leak sensitive information (stack traces, DB credentials…)
2. Synchronous error handling (easy part)
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
function divide(a, b) { if (b === 0) { throw new Error("Division by zero is not allowed"); } return a / b; } try { const result = divide(10, 0); console.log(result); } catch (err) { console.error("Caught error:", err.message); // You can decide what to do: // - recover // - log // - re-throw // - exit process (only in very rare cases) } |
Rule of thumb for sync code:
→ Always wrap risky operations in try/catch
3. Asynchronous error handling – three eras
A. Callback style (old but still everywhere)
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
const fs = require('node:fs'); fs.readFile('config.json', 'utf-8', (err, data) => { if (err) { console.error("File read failed:", err.message); // Usually: return early or call error handler return; } let config; try { config = JSON.parse(data); } catch (parseErr) { console.error("Invalid JSON:", parseErr.message); return; } console.log("Config loaded:", config); }); |
Pattern: if (err) return … is very common in callback code
B. Promise style – classic mistake
|
0 1 2 3 4 5 6 7 8 9 |
// DANGEROUS – error disappears! somePromise() .then(() => console.log("Success")) // no .catch → error is silently swallowed |
Correct minimum:
|
0 1 2 3 4 5 6 7 8 9 10 11 |
somePromise() .then(() => console.log("Success")) .catch(err => { console.error("Promise failed:", err); // decide: rethrow? recover? notify? }); |
C. async / await style – the modern standard (2025–2026)
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
async function getUserProfile(userId) { try { const user = await db.user.findUnique({ where: { id: userId } }); if (!user) { throw new Error("User not found"); // ← business error } const posts = await db.post.findMany({ where: { authorId: userId }, take: 10 }); return { user, posts }; } catch (err) { console.error("Profile fetch error:", { userId, message: err.message, code: err.code, // Prisma, Mongoose etc. often have .code stack: err.stack }); // You can re-throw or return error object throw err; } } |
Golden rule for async/await:
→ Always wrap the whole async function body in try/catch → Or make sure every await is inside some try/catch
4. Express.js – most common real-world case
Wrong way (very common beginner code)
|
0 1 2 3 4 5 6 7 8 9 10 11 |
app.get('/users/:id', async (req, res) => { const user = await db.user.findUnique({ where: { id: Number(req.params.id) } }); res.json(user); // ← if error happens → server crashes or hangs }); |
→ Unhandled rejection → process crashes (Node 15+ shows warning, later versions crash)
Correct way – pattern used in serious projects
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 |
// 1. Async handler wrapper (very popular helper) function asyncHandler(fn) { return (req, res, next) => { Promise.resolve(fn(req, res, next)) .catch(next); // ← pass error to Express error handler }; } // 2. Use it on every async route app.get('/users/:id', asyncHandler(async (req, res) => { const id = Number(req.params.id); const user = await prisma.user.findUnique({ where: { id }, include: { posts: true } }); if (!user) { return res.status(404).json({ success: false, error: "User not found" }); } res.json({ success: true, data: user }); })); // 3. Global error handler (MUST HAVE in every Express app) app.use((err, req, res, next) => { console.error("Global error handler:", { method: req.method, url: req.originalUrl, message: err.message, stack: err.stack, status: err.status || 500 }); const status = err.status || 500; const message = status === 500 ? "Internal server error" : err.message; res.status(status).json({ success: false, error: message, // in development only: ...(process.env.NODE_ENV !== 'production' && { stack: err.stack }) }); }); |
Why this pattern is so popular:
- Every async route is automatically protected
- One central place for logging & formatting errors
- No need to repeat try/catch in every route
- Clean HTTP responses even when things break
5. Recommended error types / classes (production style)
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
class AppError extends Error { constructor(message, statusCode) { super(message); this.statusCode = statusCode; this.status = `${statusCode}`.startsWith('4') ? 'fail' : 'error'; this.isOperational = true; // trusted error vs programming error Error.captureStackTrace(this, this.constructor); } } // Usage if (!user) { throw new AppError("User not found", 404); } if (!canAccessResource) { throw new AppError("Forbidden", 403); } |
Benefits:
- You can check err.isOperational to decide whether to send message to client
- Clear distinction between bugs and expected failures
6. Quick reference – Where to catch errors
| Place / Situation | Where to catch / handle | Pattern / Tool |
|---|---|---|
| Sync functions | try/catch | Standard |
| Promise chains | .catch() at the end | .catch(err => …) |
| async / await functions | try/catch around await calls | Recommended |
| Express async routes | asyncHandler wrapper or global error middleware | Very common in production |
| Unhandled promise rejections | process.on(‘unhandledRejection’, …) | Must have in production |
| Uncaught exceptions | process.on(‘uncaughtException’, …) | Last resort – usually log & exit |
| Top-level startup code | Top-level try/catch or .catch() | main().catch(err => …) |
Summary – Rules of thumb (2025–2026 style)
- Always use try/catch in async functions or asyncHandler wrapper in Express
- Never leave promises without .catch() or await in try/catch
- Always have a global error handler in Express/Fastify/Hono
- Log meaningful context (request url, user id, error code…)
- Never send stack traces to clients in production
- Use custom error classes (AppError, ValidationError, AuthError…) for better control
- Listen for unhandled rejections — even if you think you caught everything
Would you like to go deeper into any part?
- Full production-ready Express error handling boilerplate
- Custom error classes + status codes mapping
- Handling errors in Prisma / Mongoose / TypeORM
- Graceful shutdown on uncaught errors
- Logging best practices (Winston, Pino, structured logging)
- Testing error handling (supertest + custom errors)
Just tell me which direction feels most useful right now — I’ll continue with concrete code examples. 😄
