Skip to main content

Error handling patterns in Node.js

Node.js error handling patterns - the conventions for catching and propagating errors across callback, Promise, and async/await code so a single unhandled failure does not crash the whole server.

Theory

TL;DR

  • Two categories: operational errors (DB timeout, file not found) you handle and recover from; programmer errors (TypeError, bad argument) you fix and restart
  • Callbacks use error-first convention: first argument is always err or null
  • Promises use .catch(), async functions use try/catch
  • Express needs a 4-argument middleware (err, req, res, next) placed after all routes
  • uncaughtException must call process.exit(1) - the process state is corrupted after one fires

Quick example

The most common modern pattern:

js
async function loadUserProfile(userId) { try { const user = await db.users.findById(userId); if (!user) { throw new NotFoundError('User'); // custom error carries status code } return user; } catch (err) { logger.error({ userId, err }, 'Failed to load user profile'); throw err; // re-throw so Express error middleware handles the response } }

One try/catch wraps every await in the function. Any rejection from any of them lands in catch. No per-call .catch() chaining needed.

Operational vs programmer errors

This distinction comes up in every senior-level interview on this topic. Operational errors are expected: a database connection drops, a user sends invalid input, an S3 file does not exist. You anticipate these and write code that recovers - return a 404, retry once, log a warning.

Programmer errors are bugs. A TypeError: Cannot read properties of undefined means the code is wrong. You do not recover from these gracefully. You log them, exit, and let PM2 or another process manager restart the server.

Mixing the two is where most production incidents start.

Error-first callbacks

Before Promises, every async Node.js API used this convention:

js
const fs = require('fs'); fs.readFile('./config.json', 'utf8', (err, data) => { if (err) { // err.code is 'ENOENT' for missing file, 'EACCES' for permission denied console.error('Could not read config:', err.code); return; // stop - data is undefined here } const config = JSON.parse(data); startServer(config); });

Always check err first, always return after handling it. Skip the return and the code below runs with data being undefined. This pattern still appears in Node.js core APIs (fs, dns, crypto) and in older codebases.

Promises and .catch()

Errors propagate down the Promise chain until a .catch() picks them up:

js
fetchUser(id) .then(user => enrichWithPosts(user)) // rejection here skips to .catch .then(enriched => formatResponse(enriched)) .catch(err => { console.error('Pipeline failed:', err.message); return { error: err.message }; // return a fallback to recover }) .finally(() => metrics.record('fetchUser')); // runs on success and failure

.finally() is good for cleanup that should always happen: closing connections, recording timing metrics, releasing locks.

async/await with try/catch

async/await is syntactic sugar over Promises. The error model is identical, but the code reads like synchronous logic:

js
async function processOrder(orderId) { let order; try { order = await db.orders.findById(orderId); } catch (err) { throw new DatabaseError('Could not fetch order', err); } try { await paymentService.charge(order.totalAmount); } catch (err) { throw new PaymentError('Charge failed', err); } return order; }

Splitting into separate try/catch blocks gives you different error types per step. One big try/catch is fine too when you want uniform handling.

Express error middleware

Express recognizes an error middleware by its 4-argument signature. Place it after all routes:

js
// Wrap async handlers so thrown errors reach next() function asyncHandler(fn) { return (req, res, next) => { Promise.resolve(fn(req, res, next)).catch(next); }; } app.get('/users/:id', asyncHandler(async (req, res) => { const user = await User.findById(req.params.id); if (!user) throw new NotFoundError('User'); res.json(user); })); // Central error handler - must be the LAST middleware app.use((err, req, res, next) => { const status = err.statusCode || 500; res.status(status).json({ error: err.message, stack: process.env.NODE_ENV === 'development' ? err.stack : undefined, }); });

Without asyncHandler, async errors from route handlers never reach the error middleware. The wrapper calls .catch(next), which hands the error to Express's internal error pipeline.

Custom error classes

Throwing new Error('not found') works. But HTTP APIs need status codes, and your error middleware needs to distinguish your own errors from unexpected ones in third-party libraries:

js
class AppError extends Error { constructor(message, statusCode = 500) { super(message); this.statusCode = statusCode; this.name = this.constructor.name; Error.captureStackTrace(this, this.constructor); // stack starts at throw site } } class NotFoundError extends AppError { constructor(resource) { super(`${resource} not found`, 404); } } class ValidationError extends AppError { constructor(field, issue) { super(`${field}: ${issue}`, 422); this.field = field; } }

The error middleware checks err instanceof AppError to separate expected errors from surprises. Error.captureStackTrace keeps the stack trace pointing at your throw site, not inside the constructor.

Global process handlers

These catch anything that slipped past every try/catch and .catch():

js
// Node.js 15+ crashes by default on unhandled rejections // This handler lets you log before exit process.on('unhandledRejection', (reason, promise) => { console.error('Unhandled rejection:', reason); process.exit(1); }); // Synchronous throws that escaped all try/catch process.on('uncaughtException', (err) => { console.error('Uncaught exception:', err); process.exit(1); // required - process state is undefined after this });

I've seen teams add process.on('uncaughtException') thinking it makes the server fault-tolerant. It does not. It hides crashes and runs broken code. Proper try/catch coverage plus a process manager that restarts on exit is the actual solution.

Common mistakes

Swallowing errors in async functions:

js
// Wrong - caller receives undefined, error disappears async function getUser(id) { try { return await fetchUser(id); } catch (err) { console.error(err); // logs, but doesn't re-throw } } // Correct async function getUser(id) { try { return await fetchUser(id); } catch (err) { console.error(err); throw err; } }

Missing asyncHandler in Express:

js
// Wrong - rejection is unhandled, never reaches error middleware app.get('/user/:id', async (req, res) => { const user = await User.findById(req.params.id); res.json(user); });

Using Promise.all() when partial failure is acceptable:

js
// If any one fails, all fail const [users, posts] = await Promise.all([fetchUsers(), fetchPosts()]); // Promise.allSettled() returns results for each, regardless of failure const results = await Promise.allSettled([fetchUsers(), fetchPosts()]); const users = results[0].status === 'fulfilled' ? results[0].value : []; const posts = results[1].status === 'fulfilled' ? results[1].value : [];

Catching too broadly:

js
// Catches TypeErrors from bugs as well as operational failures try { const data = await fetchData(); processData(data); // a bug here gets swallowed and returns 500 } catch (err) { res.status(500).json({ error: 'Something went wrong' }); }

Separate the fetch (which can fail operationally) from the processing (which should not fail if the code is correct).

Real-world usage

  • Express APIs: asyncHandler wrapper on every route + central error middleware
  • Database layer: catch specific error codes (e.g. Postgres 23505 for unique violation) and throw ValidationError
  • External API calls: axios rejects on non-2xx, wrap in try/catch and check err.response.status
  • File operations: check err.code === 'ENOENT' to separate missing file from permission error
  • Parallel data loading: Promise.allSettled() when each piece is optional

Follow-up questions

Q: What is the difference between unhandledRejection and uncaughtException?
A: unhandledRejection fires when a rejected Promise has no .catch() handler attached. uncaughtException fires when a synchronous throw escapes all try/catch blocks. Both require process.exit(1).

Q: Why must uncaughtException call process.exit(1)?
A: After an uncaught exception, the process state is undefined. Open database connections, in-flight timers, and pending I/O may be in inconsistent states. Continuing to handle requests means serving potentially corrupted data. Exit and restart is always safer.

Q: How does Express know which middleware handles errors?
A: Express checks the function's .length property. A function with exactly 4 parameters (err, req, res, next) is treated as an error handler. When next(err) is called, Express skips all normal middlewares until it finds one with 4 parameters.

Q: What does Error.captureStackTrace(this, this.constructor) do?
A: It sets the stack trace to start at the point where you called throw, not inside the AppError constructor. Without it, the top of every stack trace shows the constructor, which tells you nothing about where the error originated.

Q: How do you handle errors from Promise.all() where you want partial results?
A: Replace Promise.all() with Promise.allSettled(). It always resolves with an array where each item has status: 'fulfilled' and value, or status: 'rejected' and reason. Filter by status to extract what succeeded.

Examples

Async route with central error handling

js
const express = require('express'); const app = express(); class AppError extends Error { constructor(message, statusCode = 500) { super(message); this.statusCode = statusCode; Error.captureStackTrace(this, this.constructor); } } class NotFoundError extends AppError { constructor(resource) { super(`${resource} not found`, 404); } } function asyncHandler(fn) { return (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next); } app.get('/orders/:id', asyncHandler(async (req, res) => { const order = await Order.findById(req.params.id); if (!order) throw new NotFoundError('Order'); res.json(order); })); // Placed after all routes app.use((err, req, res, next) => { res.status(err.statusCode || 500).json({ error: err.message }); });

Route handlers stay clean. All error shaping happens in one place.

Wrapping callback APIs with util.promisify

js
const fs = require('fs'); const { promisify } = require('util'); const readFile = promisify(fs.readFile); async function loadConfig(filePath) { try { const raw = await readFile(filePath, 'utf8'); return JSON.parse(raw); } catch (err) { if (err.code === 'ENOENT') { // operational: file missing, return defaults return { port: 3000, debug: false }; } // unexpected: JSON parse error or permission denied - re-throw throw err; } }

Checking err.code separates the expected case (file does not exist yet) from a genuine problem (invalid JSON, disk error).

Promise.allSettled() for a dashboard endpoint

js
async function getDashboardData(userId) { const [ordersResult, profileResult, notificationsResult] = await Promise.allSettled([ fetchOrders(userId), fetchProfile(userId), fetchNotifications(userId), ]); return { orders: ordersResult.status === 'fulfilled' ? ordersResult.value : [], profile: profileResult.status === 'fulfilled' ? profileResult.value : null, notifications: notificationsResult.status === 'fulfilled' ? notificationsResult.value : [], errors: [ordersResult, profileResult, notificationsResult] .filter(r => r.status === 'rejected') .map(r => r.reason?.message), }; }

The dashboard loads even when one service is down. Each section either returns real data or a safe fallback, and the errors array tells the client which parts failed.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?