Skip to content
Laviyx Learn

Try

Node.js and Express course menu

Error Handling in Express 5: Async Errors and Error Middleware

Handle errors properly in Express: how Express 5 catches errors thrown in async route handlers, writing error-handling middleware with four arguments, custom error classes with status codes, JSON 404 and 500 responses, logging without leaking details, and handling uncaught errors in Node.js.

Intermediate10 min readUpdated
On this page

Things go wrong in every server: invalid input, missing records, a database that times out, a bug. Good error handling means the server never crashes because of one bad request, users get a clear response, and developers get details in the logs.

Errors in route handlers#

If a route handler throws, or an async handler's promise rejects, Express catches it and passes it to error-handling middleware. Automatic handling of async errors is new in Express 5; in Express 4 a rejected promise was not caught, so code had to wrap handlers in try/catch and call next(err).

default.js (JavaScript)
import express from "express";

const app = express();

app.get("/api/sync-error", (req, res) => {
  throw new Error("Something broke");
});

app.get("/api/async-error", async (req, res) => {
  await new Promise((resolve) => setTimeout(resolve, 10));
  throw new Error("Database timed out");
});

app.get("/api/ok", (req, res) => res.json({ ok: true }));

app.listen(3000);
Terminal
curl -s -o /dev/null -w "%{http_code}\n" localhost:3000/api/sync-error
curl -s -o /dev/null -w "%{http_code}\n" localhost:3000/api/async-error
curl -s localhost:3000/api/ok
Output
500
500
{"ok":true}

Both errors became 500 responses and the server kept running (the last request still works). The default response is an HTML page, which in development includes the stack trace. An API should return JSON instead, with its own error handler.

Error-handling middleware#

A middleware with four parameters, (err, req, res, next), is an error handler. Express calls it when anything earlier throws or calls next(err). Register it after all routes:

api.js (JavaScript)
import express from "express";

class HttpError extends Error {
  constructor(status, message, code) {
    super(message);
    this.status = status;
    this.code = code;
  }
}

const app = express();
app.use(express.json());

const products = [{ id: 1, name: "Notebook", stock: 3 }];

app.get("/api/products/:id", (req, res) => {
  const product = products.find((p) => p.id === Number(req.params.id));
  if (!product) throw new HttpError(404, "Product not found", "PRODUCT_NOT_FOUND");
  res.json(product);
});

app.post("/api/products/:id/buy", async (req, res) => {
  const product = products.find((p) => p.id === Number(req.params.id));
  if (!product) throw new HttpError(404, "Product not found", "PRODUCT_NOT_FOUND");
  const quantity = Number(req.body.quantity ?? 1);
  if (quantity > product.stock) {
    throw new HttpError(409, `Only ${product.stock} left in stock`, "OUT_OF_STOCK");
  }
  product.stock -= quantity;
  res.json({ bought: quantity, remaining: product.stock });
});

app.get("/api/bug", () => {
  const settings = undefined;
  return settings.theme;                     // a programming error → TypeError
});

// 404 for unknown routes (after all routes)
app.use((req, res) => {
  res.status(404).json({ error: { code: "ROUTE_NOT_FOUND", message: `No route for ${req.method} ${req.path}` } });
});

// Error handler (four arguments, registered last)
app.use((err, req, res, next) => {
  if (err.type === "entity.parse.failed") {
    return res.status(400).json({ error: { code: "INVALID_JSON", message: "Request body is not valid JSON" } });
  }
  const status = err.status ?? 500;
  if (status >= 500) console.error(`[${req.method} ${req.path}]`, err.message);   // log details on the server
  res.status(status).json({
    error: {
      code: err.code ?? "INTERNAL_ERROR",
      message: status >= 500 ? "Something went wrong. Please try again later." : err.message,
    },
  });
});

app.listen(3000);
Terminal
curl -s -w " [%{http_code}]\n" localhost:3000/api/products/1
curl -s -w " [%{http_code}]\n" localhost:3000/api/products/7
curl -s -w " [%{http_code}]\n" -X POST localhost:3000/api/products/1/buy -H "Content-Type: application/json" -d '{"quantity":2}'
curl -s -w " [%{http_code}]\n" -X POST localhost:3000/api/products/1/buy -H "Content-Type: application/json" -d '{"quantity":5}'
curl -s -w " [%{http_code}]\n" -X POST localhost:3000/api/products/1/buy -H "Content-Type: application/json" -d '{"quantity":'
curl -s -w " [%{http_code}]\n" localhost:3000/api/bug
curl -s -w " [%{http_code}]\n" localhost:3000/api/nothing
Output
{"id":1,"name":"Notebook","stock":3} [200]
{"error":{"code":"PRODUCT_NOT_FOUND","message":"Product not found"}} [404]
{"bought":2,"remaining":1} [200]
{"error":{"code":"OUT_OF_STOCK","message":"Only 1 left in stock"}} [409]
{"error":{"code":"INVALID_JSON","message":"Request body is not valid JSON"}} [400]
{"error":{"code":"INTERNAL_ERROR","message":"Something went wrong. Please try again later."}} [500]
{"error":{"code":"ROUTE_NOT_FOUND","message":"No route for GET /api/nothing"}} [404]
Server terminal
[GET /api/bug] Cannot read properties of undefined (reading 'theme')

What this design gives you:

  • Expected errors (not found, out of stock) are thrown as HttpError with a status and code, and their message is safe to show.
  • Unexpected errors (the TypeError from a bug) become a generic 500 for the client, while the real message goes to the server log.
  • Every error, including invalid JSON and unknown routes, has the same JSON shape.

Errors outside requests#

Code that runs outside Express's request handling (timers, event listeners, start-up code) is not covered by the error middleware. Catch errors there yourself. As a last resort, Node emits process-level events:

JavaScript
process.on("unhandledRejection", (reason) => {
  console.error("Unhandled promise rejection:", reason);
  process.exit(1);
});
process.on("uncaughtException", (err) => {
  console.error("Uncaught exception:", err);
  process.exit(1);
});

After an uncaught error, the program may be in a broken state, so log it and exit; a process manager or the hosting platform restarts the server (see Deployment).

Common mistakes#

Practice#

Practice exercise

Add a route GET /api/divide?a=10&b=2 that returns { "result": 5 }, throws an HttpError(400, ...) when b is 0 or either value is not a number, and make sure the error comes back as JSON from an error handler.

Show hint

Convert with Number, check Number.isNaN, and throw. The four-argument handler formats the error.

Show solution
divide.js (JavaScript)
import express from "express";

class HttpError extends Error {
  constructor(status, message) {
    super(message);
    this.status = status;
  }
}

const app = express();

app.get("/api/divide", (req, res) => {
  const a = Number(req.query.a);
  const b = Number(req.query.b);
  if (Number.isNaN(a) || Number.isNaN(b)) throw new HttpError(400, "a and b must be numbers");
  if (b === 0) throw new HttpError(400, "Cannot divide by zero");
  res.json({ result: a / b });
});

app.use((err, req, res, next) => {
  res.status(err.status ?? 500).json({ error: err.status ? err.message : "Internal error" });
});

app.listen(3000);
Terminal
curl -s "localhost:3000/api/divide?a=10&b=4"
curl -s "localhost:3000/api/divide?a=10&b=0"
curl -s "localhost:3000/api/divide?a=ten&b=2"
Output
{"result":2.5}
{"error":"Cannot divide by zero"}
{"error":"a and b must be numbers"}

Key takeaway#

Next: Serving Static Files.

Finished this lesson?