Things will go wrong in every server: a client asks for a product that doesn't exist, sends invalid data, the database is briefly unreachable, an external API times out, or your own code has a bug. What separates a professional API from a fragile one isn't the absence of errors but how they're handled: the client gets a clear, correct response, developers get enough detail to fix the problem, and the server keeps running for everyone else.
Errors in Node fall into two broad groups. Operational errors are expected problems in a correct program: "not found", invalid input, a network timeout. You handle them and respond sensibly. Programmer errors are bugs: reading a property of undefined, calling a function that doesn't exist. They should be logged loudly and fixed, and they're a reason to let the process restart cleanly.
Express gives you a central place to deal with errors: error-handling middleware. Routes and services simply throw (or call next(err)), and one handler decides the status code, the response body and what gets logged. In this lesson you'll build custom error classes, a 404 handler, an error handler that's safe for production, and learn what to do when something escapes all of them.
Throwing and catching in async code
async function loadUser(id) {
const user = await db.users.find(id);
if (!user) throw new NotFoundError(`User ${id} not found`);
return user;
}
try {
const user = await loadUser(7);
} catch (err) {
console.error(err.message);
}- Always throw
Errorobjects (or subclasses), never strings, so you get a stack trace showing where it happened. - Wrap a lower-level error with
causeto keep the original:throw new Error('Could not save order', { cause: err }). - A rejected promise becomes a thrown error at the
await, sotry/catchhandles both.
Custom error classes
class AppError extends Error {
constructor(message, status = 500, code = 'INTERNAL') {
super(message);
this.name = this.constructor.name;
this.status = status;
this.code = code;
}
}
class NotFoundError extends AppError {
constructor(message = 'Not found') { super(message, 404, 'NOT_FOUND'); }
}
class ValidationError extends AppError {
constructor(details) { super('Invalid input', 400, 'VALIDATION'); this.details = details; }
}Subclasses carry the HTTP status and a machine-readable code, so services can throw meaningful errors without knowing anything about Express, and the error handler turns them into responses.
Error-handling middleware
Express recognises error handlers by their four parameters: (err, req, res, next). Add them after all routes:
app.use((req, res) => {
res.status(404).json({ error: { code: 'NOT_FOUND', message: 'Route not found' } });
});
app.use((err, req, res, next) => {
const status = err.status ?? 500;
if (status >= 500) console.error(err); // log real failures in full
res.status(status).json({
error: { code: err.code ?? 'INTERNAL', message: status < 500 ? err.message : 'Something went wrong' },
});
});- In Express 5, errors thrown in
asynchandlers and rejected promises reach this handler automatically. In Express 4, you had to callnext(err)yourself. - Never send stack traces or internal messages to clients in production: they reveal how your system works.
When errors escape
process.on('unhandledRejection')andprocess.on('uncaughtException')catch errors nothing else handled. Use them to log the error and then exit, because the program may be in a broken state.- A process manager or platform (Docker, Kubernetes, PM2, systemd, your host) restarts the app automatically.
- Run several instances behind a load balancer so a restart doesn't take the whole service down.
Node.js Web Syntax
Error handling patterns
throw new Error('Payment failed', { cause: err });
class NotFoundError extends Error { status = 404; }
app.get('/x', async (req, res) => { throw new NotFoundError('No x'); }); // Express 5
app.get('/y', (req, res, next) => fs.readFile('y', (err, d) => err ? next(err) : res.send(d)));
app.use((req, res) => res.status(404).json({ error: 'Not found' }));
app.use((err, req, res, next) => res.status(err.status ?? 500).json({ error: err.message }));
process.on('unhandledRejection', (err) => { console.error(err); process.exit(1); });Node.js Web Summary
- Throw Error objects or subclasses; use cause to keep the original error.
- Custom error classes carry an HTTP status and a code; services throw them without knowing about Express.
- Error middleware has four parameters and goes after all routes; add a 404 handler before it.
- Express 5 passes errors from async handlers to the error middleware automatically.
- Hide internal details from clients, log 500 errors in full, and let crashed processes restart.
Node.js Web Examples
Each example shows the code, the output it produces and what each part does.
Custom errors and a central handler
import express from 'express';
class AppError extends Error {
constructor(message, status, code) {
super(message);
this.status = status;
this.code = code;
}
}
class NotFoundError extends AppError {
constructor(what) { super(`${what} not found`, 404, 'NOT_FOUND'); }
}
const products = { 1: { id: 1, name: 'Mug' } };
const findProduct = async (id) => {
if (!products[id]) throw new NotFoundError(`Product ${id}`);
return products[id];
};
const app = express();
app.get('/products/:id', async (req, res) => res.json(await findProduct(req.params.id)));
app.get('/crash', async () => { const order = undefined; return order.total; });
app.use((req, res) => res.status(404).json({ error: { code: 'NOT_FOUND', message: 'Route not found' } }));
app.use((err, req, res, next) => {
const status = err.status ?? 500;
if (status >= 500) console.log(`LOG: ${err.name}: ${err.message}`);
res.status(status).json({ error: { code: err.code ?? 'INTERNAL', message: status < 500 ? err.message : 'Something went wrong' } });
});
const server = app.listen(0, async () => {
const base = `http://localhost:${server.address().port}`;
for (const p of ['/products/1', '/products/9', '/crash', '/nowhere']) {
const r = await fetch(base + p);
console.log(r.status, await r.text());
}
server.close();
});200 {"id":1,"name":"Mug"}
404 {"error":{"code":"NOT_FOUND","message":"Product 9 not found"}}
LOG: TypeError: Cannot read properties of undefined (reading 'total')
500 {"error":{"code":"INTERNAL","message":"Something went wrong"}}
404 {"error":{"code":"NOT_FOUND","message":"Route not found"}}Line-by-line explanation
- Lines 3 to 9A base class carries a status code and a machine-readable code.
- Line 15The service throws a meaningful error without knowing about HTTP.
- Line 21Express 5 catches the rejected promise and sends it to the error handler.
- Line 22A programmer error, a bug, also ends up in the same place.
- Line 24The 404 handler runs when no route matched.
- Lines 25 to 29The error handler has four parameters; it logs real failures and hides their details from clients.
Wrapping errors with cause
class DatabaseError extends Error {}
async function queryDb() {
const err = new Error('connect ECONNREFUSED 127.0.0.1:5432');
err.code = 'ECONNREFUSED';
throw err;
}
async function saveOrder(order) {
try {
await queryDb(order);
} catch (err) {
throw new DatabaseError(`Could not save order ${order.id}`, { cause: err });
}
}
try {
await saveOrder({ id: 1042 });
} catch (err) {
console.log(err instanceof DatabaseError, err.message);
console.log(`Caused by: ${err.cause.code}`);
console.log(err.stack.split('\n')[0]);
}true Could not save order 1042
Caused by: ECONNREFUSED
Error: Could not save order 1042Line-by-line explanation
- Lines 4 to 6A low-level error, like the ones database drivers throw, with a code.
- Line 13The higher-level error explains what the app was doing and keeps the original as cause.
- Line 20instanceof lets callers handle each kind of error differently.
- Line 21The original error is still available for logging and debugging.
- Line 22The stack trace starts with the error's name and message.
Retrying operational errors
import { setTimeout as sleep } from 'node:timers/promises';
let calls = 0;
async function flakyApi() {
calls++;
if (calls < 3) {
const err = new Error('Service unavailable');
err.status = 503;
throw err;
}
return { rate: 1.17 };
}
async function withRetry(fn, attempts = 4) {
for (let i = 1; i <= attempts; i++) {
try {
return await fn();
} catch (err) {
const retryable = err.status === 503 || err.code === 'ECONNRESET';
if (!retryable || i === attempts) throw err;
console.log(`Attempt ${i} failed (${err.message}), retrying`);
await sleep(10 * 2 ** i);
}
}
}
console.log(await withRetry(flakyApi));
console.log(`Succeeded after ${calls} calls`);Attempt 1 failed (Service unavailable), retrying
Attempt 2 failed (Service unavailable), retrying
{ rate: 1.17 }
Succeeded after 3 callsLine-by-line explanation
- Lines 6 to 10The fake API fails twice with a temporary 503 error, then succeeds.
- Line 19Only temporary, operational errors are retried; bugs and 400 errors are not.
- Line 20After the last attempt, the error is passed on to the caller.
- Line 22The wait doubles each time, which is called exponential backoff.
Common Node.js Web Mistakes
Throwing strings
throw 'User not found'; // no stack trace, no status, instanceof checks failthrow new NotFoundError('User 7');Error handler with three parameters
app.use((err, req, res) => { // Express treats this as normal middleware
res.status(500).json({ error: err.message });
});app.use((err, req, res, next) => {
res.status(500).json({ error: 'Something went wrong' });
});Leaking internals to clients
res.status(500).json({ error: err.stack }); // reveals file paths, libraries and querieslogger.error(err);
res.status(500).json({ error: { code: 'INTERNAL', message: 'Something went wrong' } });Node.js Web in Real Projects
Error handling in production
Production apps send errors to a logger such as pino and to monitoring services like Sentry, which group them, show stack traces and alert the team. APIs return a consistent error format, often based on the "problem details" standard, with a request ID so support can find the matching log entry. Processes run under Docker or Kubernetes with health checks, so a crashed instance is replaced automatically within seconds.
Node.js Web FAQ
What is the difference between operational errors and programmer errors?
Operational errors are expected problems in a correct program: a file that doesn't exist, a database that's briefly unreachable, invalid input from a user. You handle them with retries, fallbacks or clear error responses. Programmer errors are bugs, such as reading a property of undefined; the right fix is to change the code, and in a server the safest reaction to an unexpected one is to log it and let the process restart.
Why isn't my Express error handler being called?
Check three things. The handler must have exactly four parameters, (err, req, res, next), even if you don't use next. It must be added after all routes and other middleware. And in Express 4, errors inside async code only reach it if you call next(err); Express 5 forwards rejected promises automatically.
How do I get the full stack trace of an error in Node.js?
Log the error object itself, console.error(err), rather than just err.message: Node prints the stack and any cause chain. The text is also available as err.stack. Throwing real Error objects is what makes this work, because a thrown string has no stack at all.
Should I use try/catch around every await?
No. Wrap an await only where you can do something useful with the failure, such as retrying, using a default or adding context with cause. Elsewhere, let the error travel up to a single place, like Express's error-handling middleware, which logs it and sends one consistent response.
Is it safe to keep a Node.js server running after an uncaughtException?
No. An uncaught exception means code stopped partway through, possibly leaving connections, locks or data in an inconsistent state. Log the error with as much detail as you can, then exit with a non-zero code and let your process manager or platform start a fresh instance.
Node.js Web Exercises
Answer each one and check it instantly. If you get stuck, open the hint before the answer.
Complete the error-handling middleware's parameter list.
app.use((err, req, res, ) => {
res.status(500).json({ error: 'Something went wrong' });
});
Where should the error-handling middleware be added?
What's an operational error?
What does this print?
try {
throw new Error('outer', { cause: new Error('inner') });
} catch (e) {
console.log(e.cause.message);
}
What should an uncaughtException handler normally do?
Node.js Web Quiz
5 questions to check what you learned.
Which status code should an unexpected server bug produce?
- 400
- 404
- 500
- 200
How does Express 5 handle an error thrown in an async route?
- It crashes the server
- It passes the error to the error-handling middleware
- It returns 200
- It ignores it
Why give custom errors a status property?
- For speed
- JavaScript requires it
- To hide the message
- So the central error handler can choose the right HTTP status
Why throw Error objects instead of strings?
- Error objects include a stack trace and can be checked with instanceof
- Strings can't be caught
- Strings are slower
- There's no difference
Which errors are worth retrying automatically?
- Validation errors
- 404 Not Found
- Temporary failures such as 503 or a reset connection
- Syntax errors