Skip to content
Express.js interview 4 min read

Error & Async Handling Interview Questions

Error handling separates toy Express apps from production-grade ones, and interviewers know it. The hard questions are rarely about syntax — they probe whether you understand how Express routes errors through the four-argument middleware, why rejected promises slip through the cracks in Express 4, and what changed in Express 5. The Q&A below walks through each of these with precise, runnable answers.

How does Express identify error-handling middleware?

Express distinguishes error middleware purely by arity: a middleware function declared with four parameters — (err, req, res, next) — is treated as an error handler. Regular middleware has three. This is the single most quoted fact on the topic, and the fourth parameter is mandatory even if you never use it, because Express inspects fn.length to decide how to invoke it.

import express from "express";
const app = express();

app.get("/boom", (req, res, next) => {
  next(new Error("Something failed"));
});

// four args => Express recognizes this as an error handler
app.use((err, req, res, next) => {
  console.error(err.stack);
  res.status(err.status || 500).json({ error: err.message });
});

app.listen(3000);

Output:

{ "error": "Something failed" }

Error middleware must be registered after all routes and normal middleware, so that errors propagated upstream actually reach it.

How do you forward an error to the error handler?

Synchronous throws inside a route handler are caught by Express automatically and routed to error middleware. For anything asynchronous, you forward the error explicitly by passing it to next(err). Any truthy argument to next is treated as an error and causes Express to skip every remaining regular middleware, jumping straight to the next four-argument handler.

app.get("/orders/:id", (req, res, next) => {
  if (!/^\d+$/.test(req.params.id)) {
    return next(new Error("Invalid id")); // skip to error handler
  }
  res.json({ id: req.params.id });
});

Tip: next() advances to the next normal handler; next(err) jumps to error handling; next("route") skips the rest of the current route’s handlers and tries the next matching route.

Why are async errors not caught in Express 4?

This is the classic trap. In Express 4.x, a route handler that returns a rejected promise — or throws after an await — is not caught. Express only wraps the synchronous invocation of the handler; once control returns to the event loop, the rejection becomes an unhandled promise rejection and your error middleware never runs. The request hangs until the client times out.

// Express 4.x: this REJECTION is NOT caught — request hangs
app.get("/user/:id", async (req, res) => {
  const user = await db.find(req.params.id); // if this throws...
  res.json(user);                             // ...we never get here
});

The fix in Express 4 is to try/catch and forward manually:

app.get("/user/:id", async (req, res, next) => {
  try {
    const user = await db.find(req.params.id);
    res.json(user);
  } catch (err) {
    next(err); // forwarded to error middleware
  }
});

How can you avoid repeating try/catch everywhere?

A common follow-up: write a wrapper that catches rejected promises and forwards them, so handlers stay clean. This is exactly what libraries like express-async-handler do under the hood.

const asyncHandler = (fn) => (req, res, next) =>
  Promise.resolve(fn(req, res, next)).catch(next);

app.get("/user/:id", asyncHandler(async (req, res) => {
  const user = await db.find(req.params.id); // a rejection now goes to next(err)
  res.json(user);
}));

What changed in Express 5?

Express 5 makes async handling first-class: if a route or middleware function returns a promise that rejects, Express automatically forwards the error to your error-handling middleware. The manual try/catch and asyncHandler wrapper become unnecessary for the common case.

BehaviorExpress 4.xExpress 5.x
Sync throw in handlerCaught automaticallyCaught automatically
Rejected promise / async throwNOT caught — request hangsCaught and routed to error middleware
Need a wrapper helperYes, in practiceNo
path-to-regexp wildcards* styleNamed params (/*splat)
// Express 5.x: no try/catch needed
app.get("/user/:id", async (req, res) => {
  const user = await db.find(req.params.id); // rejection auto-forwarded
  res.json(user);
});

How should you handle uncaught exceptions and unhandled rejections?

Even with perfect routing, bugs escape. Express middleware can only catch errors that flow through the request pipeline — it cannot catch errors in timers, event emitters, or background jobs. For those, attach process-level listeners, log, and exit so a supervisor (PM2, systemd, Kubernetes) can restart cleanly. Continuing after an uncaught exception leaves the process in an undefined state.

process.on("unhandledRejection", (reason) => {
  console.error("Unhandled rejection:", reason);
  throw reason; // promote to uncaughtException
});

process.on("uncaughtException", (err) => {
  console.error("Uncaught exception:", err);
  process.exit(1); // let the process manager restart us
});

Warning: Do not use uncaughtException as a global try/catch to keep running. The recommended pattern is log, flush, and exit — the process is no longer trustworthy.

Best Practices

  • Define error middleware with all four arguments and register it after every route.
  • Forward errors with next(err) instead of sending responses from deep inside helpers.
  • In Express 4, wrap async handlers with a Promise.resolve(...).catch(next) helper; in Express 5 rely on built-in forwarding.
  • Centralize error responses in one handler so status codes and JSON shapes stay consistent.
  • Distinguish operational errors (bad input, 4xx) from programmer errors (bugs, 5xx) and respond accordingly.
  • Add unhandledRejection and uncaughtException listeners that log and exit, then let a supervisor restart.
  • Never leak stack traces to clients in production — return a generic message and log the detail server-side.
Last updated June 14, 2026
Was this helpful?