Error handling in Node.js is not just about adding a few try/catch blocks. In production applications, good error handling determines whether a small failure becomes a useful API response, a clear log entry, or a full application outage.
Over time, I have found that the hardest part is not catching an error. The harder part is deciding where to handle it, what to log, what to return to the client, and when the process should stop instead of continuing in an unsafe state.
In this guide, I’ll show a practical approach to error handling in Node.js using TypeScript and Express-style APIs, while also covering Promises, database errors, custom error classes, process-level failures, logging, streams, and graceful shutdown.
How Error Handling in Node.js Actually Works
Node.js errors can reach your application in several different ways.
Synchronous code
→ throws an exception
Promise-based async code
→ rejects a Promise
Callback-based APIs
→ often pass error as first callback argument
Streams / EventEmitters
→ may emit an "error" event
Process-level failures
→ uncaughtException / unhandledRejection
This distinction matters because one error-handling technique does not work everywhere.
For example, Node.js documents that synchronous APIs generally report failures by throwing, while Promise-based APIs reject and many stream-based APIs use an error event.
1. Handle Synchronous Errors with try/catch
For synchronous code, try/catch is straightforward.
function parseSettings(value: string) {
try {
return JSON.parse(value);
} catch (error) {
console.error(
'Failed to parse settings',
error
);
return null;
}
}
This works because JSON.parse() throws during the same execution flow.
The mistake is assuming that the same outer try/catch can automatically catch every asynchronous error.
2. Handle Promise Errors with async/await
For modern Node.js code, I usually prefer async/await because it keeps asynchronous logic readable.
async function getUser(
userId: number
) {
try {
const response = await fetch(
`https://api.example.com/users/${userId}`
);
if (!response.ok) {
throw new Error(
`Request failed with status ${response.status}`
);
}
return await response.json();
} catch (error) {
console.error(
'Failed to fetch user',
error
);
throw error;
}
}
Notice that I rethrow the error after logging it.
This is important when the current function does not have enough context to decide how the application should respond.
Do Not Catch Errors Just to Hide Them
One pattern I try to avoid is catching an error and then pretending everything succeeded.
For example:
async function saveOrder() {
try {
await database.save();
} catch (error) {
console.log(error);
}
return {
success: true,
};
}
This is dangerous because the caller receives success: true even when the database operation failed.
A better approach is either to handle the failure meaningfully or allow it to propagate.
async function saveOrder() {
try {
await database.save();
return {
success: true,
};
} catch (error) {
throw new Error(
'Unable to save order',
{
cause: error,
}
);
}
}
The cause property is useful because it lets you add application context without completely losing the original failure.
3. Create Application-Specific Error Classes
In larger APIs, returning 500 Internal Server Error for every failure makes debugging and client behavior harder.
I prefer defining a small application error class.
export class AppError extends Error {
constructor(
message: string,
public readonly statusCode = 500,
public readonly code = 'INTERNAL_ERROR',
public readonly isOperational = true,
options?: ErrorOptions
) {
super(message, options);
this.name =
this.constructor.name;
}
}
Now expected application failures can be much more explicit.
throw new AppError(
'User not found',
404,
'USER_NOT_FOUND'
);
or:
throw new AppError(
'Email already exists',
409,
'EMAIL_ALREADY_EXISTS'
);
4. Operational Errors vs Programming Errors
This is one of the most useful distinctions I make when designing Node.js error handling.
Operational errors
These are expected failures that can happen even when the application code is correct.
- invalid user input
- record not found
- duplicate email address
- payment declined
- third-party API timeout
- database temporarily unavailable
- rate limit exceeded
These usually need a controlled response.
Programming errors
These normally indicate a bug.
- accessing a property on
undefined - invalid assumptions in application logic
- incorrect function arguments
- unexpected invariant violations
- logic that should never be reached
Trying to silently recover from every programming error can leave the process in an unknown state.
5. Use Centralized Error Handling in Express APIs
One of the biggest improvements I make in API projects is keeping route handlers focused on application logic and moving response formatting into centralized error middleware.
A route can stay very small:
app.get(
'/users/:id',
async (req, res) => {
const user =
await userService.findById(
Number(req.params.id)
);
res.json(user);
}
);
With Express 5, rejected Promises and errors thrown by async route handlers are automatically forwarded to error-handling middleware.
Then add one centralized handler near the end of the middleware stack:
import type {
ErrorRequestHandler,
} from 'express';
export const errorHandler:
ErrorRequestHandler =
(
error,
req,
res,
next
) => {
if (res.headersSent) {
return next(error);
}
if (error instanceof AppError) {
return res
.status(error.statusCode)
.json({
error: {
code: error.code,
message: error.message,
},
});
}
console.error(error);
return res
.status(500)
.json({
error: {
code: 'INTERNAL_ERROR',
message:
'Something went wrong',
},
});
};
Then register it after your routes:
app.use(errorHandler);
Express error middleware uses the four-argument signature (err, req, res, next), which is how Express identifies it as an error handler.
6. Do Not Return Internal Error Details to Clients
A development environment and production environment should not expose the same error information.
This is useful internally:
TypeError:
Cannot read properties of undefined
at UserService.findUser
at OrderController.create
at node:internal/...
But I would not normally send that entire stack trace to a public API client.
Prefer a controlled response:
{
"error": {
"code": "INTERNAL_ERROR",
"message": "Something went wrong"
}
}
Log the detailed error on the server. Return only what the client needs.
7. Add Request Context to Logs
A log saying only:
Database failed
usually does not tell me enough.
Production logs become much more useful when they include context.
logger.error(
{
err: error,
requestId:
req.requestId,
userId:
req.user?.id,
method:
req.method,
path:
req.path,
},
'Request failed'
);
I especially like having a request or correlation ID because it makes it easier to follow one request through several services.
At the same time, I avoid logging sensitive information such as passwords, access tokens, authorization headers, or private customer data unless there is a very specific need.
8. Handle Database Errors at the Right Layer
Database libraries often return implementation-specific errors.
I generally do not want PostgreSQL, Prisma, TypeORM, or MongoDB error details leaking directly into controllers or public responses.
Instead, translate known database failures into application errors.
async function createUser(
input: CreateUserInput
) {
try {
return await repository.create(
input
);
} catch (error) {
if (
isDuplicateEmailError(
error
)
) {
throw new AppError(
'Email already exists',
409,
'EMAIL_ALREADY_EXISTS',
true,
{
cause: error,
}
);
}
throw error;
}
}
This gives the rest of the application a stable error contract even if the database implementation changes later.
9. Add Timeouts to External API Calls
A third-party API that never responds can consume resources and leave requests waiting much longer than expected.
Modern Node.js fetch() supports AbortSignal, so one practical pattern is:
async function fetchPayment(
paymentId: string
) {
const controller =
new AbortController();
const timeout =
setTimeout(
() =>
controller.abort(),
5000
);
try {
const response =
await fetch(
`https://api.example.com/payments/${paymentId}`,
{
signal:
controller.signal,
}
);
if (!response.ok) {
throw new AppError(
'Payment service failed',
502,
'PAYMENT_SERVICE_ERROR'
);
}
return await response.json();
} finally {
clearTimeout(timeout);
}
}
In production, I also think about retry behavior carefully.
Retries are useful for some temporary failures, but blindly retrying everything can make an outage worse.
10. Retry Only Errors That May Actually Recover
I do not retry errors such as:
- invalid credentials
- bad request payloads
- permission errors
- business validation failures
I may retry failures such as:
- temporary network failures
- HTTP 503 responses
- rate limiting when the provider gives a retry strategy
- temporary database connection problems
For background jobs, I normally prefer retry policies with exponential backoff rather than retrying immediately in a tight loop.
11. Be Careful with Promise.all()
Promise.all() rejects when one of the included Promises rejects.
const [
user,
orders,
notifications,
] = await Promise.all([
getUser(),
getOrders(),
getNotifications(),
]);
That is exactly what I want when all three results are required.
But sometimes partial success is acceptable.
In that situation, Promise.allSettled() may be more appropriate:
const results =
await Promise.allSettled([
getRecommendations(),
getNotifications(),
getActivityFeed(),
]);
for (const result of results) {
if (
result.status ===
'rejected'
) {
console.error(
result.reason
);
}
}
The important thing is deciding whether one failure should fail the entire operation.
12. Streams Need Their Own Error Handling
This is an area that is easy to overlook if most of your Node.js code uses Promises.
Streams and other EventEmitter-style APIs may emit an error event.
import {
createReadStream,
} from 'node:fs';
const stream =
createReadStream(
'./large-file.csv'
);
stream.on(
'error',
(error) => {
console.error(
'File stream failed',
error
);
}
);
Node.js documents that an EventEmitter emitting error without an appropriate listener can cause the process to terminate, so stream errors should not be ignored.
13. Understand unhandledRejection
An unhandled Promise rejection means a Promise rejected without an error handler being attached in time.
async function run() {
throw new Error(
'Something failed'
);
}
run();
If nothing handles that rejected Promise, it becomes a process-level problem.
Node.js exposes the unhandledRejection event for observing these failures.
process.on(
'unhandledRejection',
(reason) => {
console.error(
'Unhandled rejection',
reason
);
}
);
I treat this as a final safety net and observability mechanism—not as a replacement for handling Promise failures where they actually occur.
14. Understand uncaughtException
An uncaught exception is more serious.
process.on(
'uncaughtException',
(error) => {
console.error(
'Uncaught exception',
error
);
process.exit(1);
}
);
I do not use this event to keep the application running indefinitely.
Node.js warns that attempting to continue normal operation after an uncaught exception is unsafe because the application may be in an undefined state.
Instead, I use it to perform minimal synchronous cleanup or logging and let an external process manager, container platform, or orchestration system restart the application.
15. Gracefully Shut Down the Application
A production application should also handle normal shutdown signals.
For example:
const server =
app.listen(3000);
async function shutdown(
signal: string
) {
console.log(
`${signal} received`
);
server.close(
async () => {
try {
await database.close();
console.log(
'Shutdown complete'
);
process.exit(0);
} catch (error) {
console.error(
'Shutdown failed',
error
);
process.exit(1);
}
}
);
}
process.on(
'SIGTERM',
() =>
shutdown('SIGTERM')
);
process.on(
'SIGINT',
() =>
shutdown('SIGINT')
);
The exact implementation depends on your infrastructure, but the goal is usually to stop accepting new work, finish or terminate existing work safely, close resources, and then exit.
16. Return Consistent API Errors
I like API errors to follow one predictable structure.
{
"error": {
"code": "USER_NOT_FOUND",
"message": "User not found",
"requestId": "req_82fd3"
}
}
This makes frontend handling much easier than returning a different response format from every endpoint.
For example, the frontend can make decisions based on a stable machine-readable code:
if (
error.code ===
'SESSION_EXPIRED'
) {
redirectToLogin();
}
Human-readable messages can change. Stable error codes are much safer for application logic.
17. Map HTTP Status Codes Intentionally
I commonly use these mappings:
| Status | Typical Meaning |
|---|---|
| 400 | Invalid request |
| 401 | Authentication required or invalid |
| 403 | Authenticated but not allowed |
| 404 | Resource not found |
| 409 | Conflict such as duplicate data |
| 422 | Request understood but validation failed |
| 429 | Rate limit exceeded |
| 500 | Unexpected server failure |
| 502 | Upstream dependency failed |
| 503 | Service temporarily unavailable |
The exact choice depends on the API contract, but consistency matters more than trying to invent a unique status code for every situation.
18. Validate Before Business Logic Runs
Many avoidable runtime errors start with invalid input.
I prefer validating at the application boundary.
import { z } from 'zod';
const CreateUserSchema =
z.object({
name:
z.string().min(2),
email:
z.string().email(),
});
const result =
CreateUserSchema.safeParse(
req.body
);
if (!result.success) {
throw new AppError(
'Invalid request',
400,
'VALIDATION_ERROR'
);
}
This means deeper application layers can work with data that already matches an expected shape.
19. Avoid Catching Every Error at Every Layer
Another mistake I see is repetitive handling like this:
Controller
try/catch
↓
Service
try/catch
↓
Repository
try/catch
↓
Database
This often creates duplicated logging and errors that lose their original stack or context.
I prefer:
Repository
→ throws technical error
Service
→ translates expected domain errors
Controller
→ mostly lets errors propagate
Global handler
→ logs + formats HTTP response
Catch an error where you can actually add value.
20. My Production Error-Handling Flow
For a typical Node.js API, my preferred flow looks roughly like this:
Incoming Request
↓
Validation
↓
Controller
↓
Service
↓
Repository / External API
↓
Expected failure?
↓
Convert to AppError
↓
Global Error Handler
↓
Structured Log
↓
Safe API Response
Unexpected programming errors still reach the global handler, but I treat them differently from expected business failures.
What I Log in Production
For important failures, I usually want enough context to answer:
- What failed?
- Which request caused it?
- Which user or account was involved?
- Which service or operation failed?
- What was the original error?
- When did it happen?
A structured log might contain:
{
"level": "error",
"message": "Order creation failed",
"requestId": "req_123",
"userId": 42,
"operation": "createOrder",
"errorCode": "DATABASE_TIMEOUT"
}
The production log may also contain the stack trace internally, while the public API receives only a safe message.
Common Node.js Error-Handling Mistakes
1. Swallowing errors
try {
await runTask();
} catch {
// nothing
}
If the failure genuinely does not matter, document why. Otherwise this makes production debugging extremely difficult.
2. Returning every error as HTTP 500
Invalid input and missing resources are not internal server failures.
3. Sending stack traces to users
Stack traces belong in observability tools and internal logs, not public production responses.
4. Logging the same error five times
If every layer catches, logs, and rethrows the same error, one failure can create several identical log entries.
5. Ignoring Promise rejections
Every Promise that can fail needs a meaningful handling path.
6. Continuing after a fatal programming error
An uncaught exception may mean the application’s state is no longer trustworthy.
7. Retrying every failure
A permanent validation error will not become successful because you retry it five times.
Error Handling Checklist for Node.js APIs
- Validate input at application boundaries.
- Use meaningful application error types.
- Keep HTTP error formatting centralized.
- Return stable machine-readable error codes.
- Log detailed errors on the server.
- Do not expose secrets or stack traces publicly.
- Add request IDs for tracing.
- Set timeouts for external services.
- Retry only recoverable failures.
- Handle stream and EventEmitter errors.
- Monitor unhandled Promise rejections.
- Treat uncaught exceptions as potentially fatal.
- Implement graceful shutdown.
- Use a process manager or orchestration platform to restart failed processes.
Final Thoughts
Good error handling in Node.js is less about writing more try/catch blocks and more about designing a predictable failure path.
The approach I prefer is simple:
Validate early
→ throw meaningful errors
→ let them propagate
→ handle them centrally
→ log useful context
→ return safe responses
→ crash safely when state is unreliable
That gives developers useful debugging information without exposing internal implementation details to users.
It also makes the application easier to operate because errors are handled consistently instead of differently in every route and service.
If you are building a larger Node.js backend, you may also want to read my guide on building a production-ready REST API with NestJS and my article on Node.js libraries I use in production.



