Error Handling

Handle errors properly in Node.js apps: throw and catch in async code, create custom error classes with status codes, centralise responses in Express error-handling middleware, add a 404 handler, log useful details without leaking them, and deal with crashes safely.

Lesson 16 of 43Intermediate18 min read

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

JavaScript
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 Error objects (or subclasses), never strings, so you get a stack trace showing where it happened.
  • Wrap a lower-level error with cause to keep the original: throw new Error('Could not save order', { cause: err }).
  • A rejected promise becomes a thrown error at the await, so try/catch handles both.

Custom error classes

JavaScript
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:

JavaScript
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 async handlers and rejected promises reach this handler automatically. In Express 4, you had to call next(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') and process.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.

Example 1

Custom errors and a central handler

Node.js
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();
});
Output
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
  1. Lines 3 to 9A base class carries a status code and a machine-readable code.
  2. Line 15The service throws a meaningful error without knowing about HTTP.
  3. Line 21Express 5 catches the rejected promise and sends it to the error handler.
  4. Line 22A programmer error, a bug, also ends up in the same place.
  5. Line 24The 404 handler runs when no route matched.
  6. Lines 25 to 29The error handler has four parameters; it logs real failures and hides their details from clients.
Example 2

Wrapping errors with cause

Node.js
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]);
}
Output
true Could not save order 1042
Caused by: ECONNREFUSED
Error: Could not save order 1042
Line-by-line explanation
  1. Lines 4 to 6A low-level error, like the ones database drivers throw, with a code.
  2. Line 13The higher-level error explains what the app was doing and keeps the original as cause.
  3. Line 20instanceof lets callers handle each kind of error differently.
  4. Line 21The original error is still available for logging and debugging.
  5. Line 22The stack trace starts with the error's name and message.
Example 3

Retrying operational errors

Node.js
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`);
Output
Attempt 1 failed (Service unavailable), retrying
Attempt 2 failed (Service unavailable), retrying
{ rate: 1.17 }
Succeeded after 3 calls
Line-by-line explanation
  1. Lines 6 to 10The fake API fails twice with a temporary 503 error, then succeeds.
  2. Line 19Only temporary, operational errors are retried; bugs and 400 errors are not.
  3. Line 20After the last attempt, the error is passed on to the caller.
  4. Line 22The wait doubles each time, which is called exponential backoff.

Common Node.js Web Mistakes

Throwing strings

Don't
throw 'User not found';           // no stack trace, no status, instanceof checks fail
Do
throw new NotFoundError('User 7');

Error handler with three parameters

Don't
app.use((err, req, res) => {      // Express treats this as normal middleware
  res.status(500).json({ error: err.message });
});
Do
app.use((err, req, res, next) => {
  res.status(500).json({ error: 'Something went wrong' });
});

Leaking internals to clients

Don't
res.status(500).json({ error: err.stack });   // reveals file paths, libraries and queries
Do
logger.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.

Fill in the blankseasy

Complete the error-handling middleware's parameter list.

app.use((err, req, res, ) => {
  res.status(500).json({ error: 'Something went wrong' });
});
Multiple choiceeasy

Where should the error-handling middleware be added?

Multiple choicemedium

What's an operational error?

Predict the outputmedium

What does this print?

try {
  throw new Error('outer', { cause: new Error('inner') });
} catch (e) {
  console.log(e.cause.message);
}
Multiple choicehard

What should an uncaughtException handler normally do?

Node.js Web Quiz

5 questions to check what you learned.

  1. Which status code should an unexpected server bug produce?

    • 400
    • 404
    • 500
    • 200
  2. 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
  3. 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
  4. 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
  5. Which errors are worth retrying automatically?

    • Validation errors
    • 404 Not Found
    • Temporary failures such as 503 or a reset connection
    • Syntax errors