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.
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).
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);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/ok500
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:
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);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{"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][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
HttpErrorwith a status and code, and their message is safe to show. - Unexpected errors (the
TypeErrorfrom 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:
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
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);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"{"result":2.5}
{"error":"Cannot divide by zero"}
{"error":"a and b must be numbers"}Key takeaway#
Next: Serving Static Files.
Finished this lesson?