Episode 3 — NodeJS MongoDB Backend Architecture / 3.17 — Error Handling in Express

Interview Questions: Error Handling in Express

Prepare for technical interviews with these 11 error handling questions spanning Beginner, Intermediate, and Advanced levels, complete with "why interviewers ask" context, model answers, and a quick-fire reference table.


<< Previous: Exercise Questions | Next: Quick Revision >>


How to Use This Material

  1. Read the 3.17 README and all sub-topic files (3.17.a, 3.17.b, 3.17.c) first to build conceptual understanding
  2. Work through the Exercise Questions before reviewing these interview answers -- you learn more by struggling first
  3. Study the "Why interviewers ask" section for each question to understand what the interviewer is really evaluating
  4. Practice explaining each model answer out loud in your own words -- interviewers value clarity and confidence over rote memorization
  5. Use the Quick Revision cheat sheet for a final refresher the day before your interview
  6. Focus on Beginner questions first; most interviews start there and go deeper only if you answer well

Beginner Questions (Q1-Q4)

Q1. What happens when Express encounters an unhandled error?

Why interviewers ask: This is a foundational question that tests whether you understand Express's error propagation mechanism at all. It also reveals whether you know the critical difference between synchronous and asynchronous error behavior -- a distinction that trips up even experienced developers and is the root cause of many production bugs.

Model answer:

The behavior depends on whether the error is synchronous or asynchronous, and this distinction is crucial.

Synchronous errors are caught automatically. Express wraps synchronous route handlers and middleware in an internal try-catch. If you throw new Error('...') inside a synchronous handler, Express catches it and forwards it to the error-handling middleware by internally calling next(err). The client receives a proper error response.

// Sync: Express catches this automatically
app.get('/sync', (req, res) => {
  throw new Error('Caught by Express');
  // Express internally does: next(new Error('Caught by Express'))
});

Asynchronous errors in Express 4.x are NOT caught. This is the dangerous case. Async functions return Promises, and Express 4.x does not attach a .catch() handler to the returned Promise. If an await rejects or an error is thrown inside an async handler, the following cascade happens:

  1. The async function returns a rejected Promise
  2. Express 4.x ignores the returned Promise entirely
  3. No response is sent to the client
  4. The request hangs indefinitely until the client times out
  5. Node.js emits an unhandledRejection warning to the console
  6. If no process.on('unhandledRejection') handler exists, future versions of Node.js may terminate the process
// Async: Express 4.x does NOT catch this!
app.get('/async', async (req, res) => {
  const users = await User.find(); // If DB is down, this rejects
  res.json(users);
  // The rejected Promise goes unhandled. Request hangs. No error response.
});

This is why Express 4.x applications require one of three solutions: manual try...catch blocks with next(err), the asyncHandler utility wrapper, or the express-async-errors package. Express 5.x fixes this natively by automatically catching rejected Promises returned from handlers.

If no error-handling middleware is registered at all, Express uses a built-in default handler that sends a plain-text stack trace in development and a simple "Internal Server Error" in production.


Q2. What is the difference between operational errors and programmer errors?

Why interviewers ask: This distinction is fundamental to building production-ready systems. Interviewers want to see that you can classify errors correctly and respond appropriately to each kind. A developer who treats all errors the same -- either exposing every stack trace or hiding every message -- will build either insecure or unusable APIs.

Model answer:

Operational errors are errors you anticipate in normal application operation. They result from external conditions, not from bugs in your code. The application is working correctly -- it simply encountered a situation it needs to handle gracefully.

ExampleStatus CodeWhy It Happens
User requests a resource that does not exist404The resource was deleted or the ID is wrong
Request body fails validation400Missing required field, invalid email format
Authentication token is expired or malformed401User's session timed out or token was tampered with
Duplicate email during registration409Another user already registered with that email
Database is temporarily unreachable503Network issue, database under maintenance
Rate limit exceeded429Client is sending too many requests

These errors should return the appropriate HTTP status code with a helpful, user-facing message. They are recoverable -- the client can fix the input, retry later, or re-authenticate.

Programmer errors are bugs in your code. They should never happen in a correctly written application. They indicate that something is fundamentally wrong with the logic.

ExampleCause
TypeError: Cannot read properties of undefinedAccessing a property on an undefined variable
ReferenceError: x is not definedUsing an undeclared variable
Calling a function with wrong argument typesLogic error in code
Reading from a closed database connectionResource management bug
Infinite recursion causing stack overflowLogic error

These errors should result in a generic 500 "Something went wrong" response in production -- never expose internal details, stack traces, or file paths. They must be logged with full stack traces for developers to investigate. In severe cases (uncaught exceptions), the process should be restarted because the application state may be corrupted.

The key design pattern that implements this distinction is the isOperational flag on a custom ApiError class:

// Operational: safe to show the message to the client
throw new ApiError(404, 'User not found'); // isOperational defaults to true

// Programmer: hide details in production, log for developers
throw new ApiError(500, 'Database pool exhausted', false); // isOperational = false

In the centralized error handler, operational errors return their actual message in production, while non-operational errors return "Something went wrong" to prevent information leakage. This single flag drives the entire production vs development response logic.


Q3. How does next(err) work in Express?

Why interviewers ask: The next() function is the backbone of Express's middleware architecture. Understanding its dual behavior -- normal flow vs error flow -- is essential for debugging any Express application. Interviewers use this question to gauge whether you truly understand how Express processes requests.

Model answer:

The next() function in Express has fundamentally different behavior depending on whether it is called with or without an argument.

next() without arguments passes control to the next regular middleware or route handler in the chain. The request continues flowing through the middleware stack in registration order:

Request → Middleware A → next() → Middleware B → next() → Route Handler → Response

next(err) with an argument tells Express that an error occurred. Express skips all remaining regular middleware and route handlers and jumps directly to the first error-handling middleware -- the one with the four-parameter signature (err, req, res, next).

Request → Middleware A → next(err) ──SKIP B──SKIP Route──> Error Handler → Response

Here is a concrete example:

app.get('/api/users/:id', async (req, res, next) => {
  try {
    const user = await User.findById(req.params.id);
    if (!user) {
      return next(new ApiError(404, 'User not found'));
      // ↑ Skips everything below, jumps to error handler
    }
    res.json(user);
  } catch (err) {
    next(err); // Forward database errors to the error handler
  }
});

// This middleware is SKIPPED when next(err) is called above
app.use(logRequestMiddleware);

// Error handler catches the error
app.use((err, req, res, next) => {
  res.status(err.statusCode || 500).json({ message: err.message });
});

Critical details to mention in an interview:

  1. Always use return next(err) -- without return, the code after next(err) continues to execute, which can cause "Cannot set headers after they are sent" errors if you later call res.json().

  2. Express identifies error-handling middleware by its function having exactly 4 declared parameters. Express checks fn.length === 4. Even if you do not use next inside the error handler, you must include it as the fourth parameter. Omitting it causes Express to treat the function as regular middleware.

  3. There is a special case: next('route') (passing the string 'route') skips remaining handlers on the current route and moves to the next matching route. This is different from next(err) which jumps to error-handling middleware.

  4. If next(err) is called but no error-handling middleware is registered, Express uses its built-in default handler that sends a plain-text stack trace in development or a 500 status in production.


Q4. Why should you not use try...catch in every single route handler?

Why interviewers ask: This tests code quality awareness, DRY principles, and knowledge of production patterns like asyncHandler. Interviewers want to see that you can identify boilerplate, understand its risks, and know the standard solution used across the industry.

Model answer:

While try...catch with next(err) is technically correct for handling async errors in Express 4.x, manually adding it to every route handler creates several compounding problems:

1. Massive boilerplate. A real application has 50-200+ async route handlers. Every single one needs the identical try { ... } catch (err) { next(err); } wrapper. This adds 3-4 lines of structural noise to every handler and pushes the actual business logic deeper into nesting.

2. Easy to forget. It only takes one handler where a developer forgets the try-catch for async errors to go completely unhandled. The request hangs silently -- no error is logged, no response is sent, and the bug is extremely hard to detect until a user reports a timeout. In code review, it is also easy to miss the absence of a try-catch.

3. Easy to get wrong. Common mistakes include: catching the error but forgetting to call next(err) (error is swallowed silently); sending a response with res.json() AND calling next(err) (causes "Cannot set headers after they are sent"); or catching only some errors and leaving others unhandled.

4. Reduced readability. The core route logic -- the part that actually matters for understanding what the route does -- gets buried inside try-catch nesting. Code reviews become harder, and maintenance is more error-prone.

// WITHOUT asyncHandler — every route looks like this:
app.get('/api/users', async (req, res, next) => {
  try {
    const users = await User.find();
    res.json({ success: true, data: users });
  } catch (err) {
    next(err);
  }
});

// WITH asyncHandler — clean, focused on business logic:
app.get('/api/users', asyncHandler(async (req, res) => {
  const users = await User.find();
  res.json({ success: true, data: users });
}));

The standard solution is the asyncHandler higher-order function:

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

This single utility eliminates all try-catch boilerplate, guarantees that every async error is forwarded to the centralized error handler, and lets route handlers focus purely on business logic. It also works for async middleware (like authentication), not just route handlers.

An alternative is the express-async-errors package, which monkey-patches Express globally so no wrapping is needed at all -- you just require('express-async-errors') once at the top of your app. The trade-off is explicitness vs convenience: asyncHandler makes it obvious which routes are wrapped, while express-async-errors is implicit.


Intermediate Questions (Q5-Q8)

Q5. What is the error-handling middleware signature in Express, and why does it require exactly 4 parameters?

Why interviewers ask: This is a notorious Express gotcha that has caused countless hours of debugging. Interviewers use it to test whether you understand Express's internal mechanism for distinguishing middleware types -- a detail that separates developers who have read the documentation from those who have debugged a real production issue.

Model answer:

The error-handling middleware signature in Express is:

app.use((err, req, res, next) => {
  // Handle the error here
});

It must have exactly four parameters: err, req, res, and next. Express uses the function's .length property (the count of declared parameters) to determine whether a middleware function is a regular middleware or an error-handling middleware. Specifically, Express checks fn.length === 4.

This has a critical implication: if you accidentally define your error handler with only 3 parameters, Express will never call it as an error handler. It will be treated as regular middleware where the err argument is interpreted as req:

// WRONG: 3 parameters — Express treats this as regular middleware (req, res, next)
app.use((err, req, res) => {
  // This function is NEVER called when an error occurs.
  // Express sees 3 params: 'err' becomes req, 'req' becomes res, 'res' becomes next.
});

// CORRECT: 4 parameters — Express recognizes this as an error handler
app.use((err, req, res, next) => {
  res.status(err.statusCode || 500).json({
    success: false,
    message: err.message,
  });
});

Even if you do not call next inside the error handler (because it is typically the last middleware in the chain), you must include it as the fourth parameter. Without it, Express cannot identify the function as an error handler.

Additional rules for error-handling middleware:

  1. Must be registered after all routes using app.use(errorHandler). Express processes middleware in registration order, and errors need to "fall through" from routes to the error handler.

  2. You can chain multiple error handlers. Each receives the error, processes it, and either sends a response or passes the error along with next(err):

// Error logger — logs and passes along
const logErrors = (err, req, res, next) => {
  console.error(err.stack);
  next(err); // Pass to next error handler
};

// Error responder — sends the response
const sendError = (err, req, res, next) => {
  res.status(err.statusCode || 500).json({ message: err.message });
};

app.use(logErrors);
app.use(sendError);
  1. Arrow functions and destructured parameters can break this mechanism because they may change fn.length. Always use all four named parameters explicitly.

Q6. How would you design a custom ApiError class for a production Express application?

Why interviewers ask: Custom error classes are a standard pattern in production Node.js applications. This question tests your understanding of JavaScript class inheritance, the Error prototype chain, and your ability to design abstractions that support clean, consistent API responses. A strong answer demonstrates both technical knowledge and architectural thinking.

Model answer:

A production ApiError class extends the native Error class and adds HTTP-specific properties that the centralized error handler uses to generate consistent responses:

class ApiError extends Error {
  constructor(statusCode, message, isOperational = true, stack = '') {
    super(message);
    this.statusCode = statusCode;
    this.isOperational = isOperational;
    this.status = `${statusCode}`.startsWith('4') ? 'fail' : 'error';

    if (stack) {
      this.stack = stack;
    } else {
      Error.captureStackTrace(this, this.constructor);
    }
  }
}

Each property serves a specific purpose:

statusCode (e.g., 400, 401, 404, 500): The HTTP status code that the error handler will set on the response. This removes the guesswork from the centralized handler -- it does not need to parse the message or infer a status code.

message (via super(message)): A human-readable description of the error. For operational errors, this message is safe to show to clients ("User not found"). For programmer errors, the centralized handler replaces it with a generic message in production.

isOperational (defaults to true): The critical flag that distinguishes expected errors from bugs. In production, operational errors (isOperational: true) return their actual message. Non-operational errors (isOperational: false) return "Something went wrong" to avoid leaking internal details like database column names, query structures, or file paths.

status ('fail' or 'error'): A string derived from the status code. 4xx errors get 'fail' (the client made a mistake), 5xx errors get 'error' (the server had a problem). This follows the JSend specification and provides clients with a quick way to categorize the response type without parsing the numeric code.

Error.captureStackTrace(this, this.constructor): Ensures the stack trace begins at the line where new ApiError(...) was called, not inside the ApiError constructor itself. This makes debugging significantly easier because the first line of the stack trace points directly to the source of the error.

In practice, you can also create specialized subclasses for common error types to reduce repetition:

class NotFoundError extends ApiError {
  constructor(message = 'Resource not found') { super(404, message); }
}
class UnauthorizedError extends ApiError {
  constructor(message = 'Not authorized') { super(401, message); }
}
class ValidationError extends ApiError {
  constructor(message = 'Validation failed') { super(400, message); }
}

This allows route handlers to write throw new NotFoundError('User not found') instead of throw new ApiError(404, 'User not found') -- more readable and less prone to status code typos.


Q7. How do you handle async errors in Express 4 vs Express 5?

Why interviewers ask: This is arguably the most important behavioral difference between Express versions, and it directly affects how you architect error handling in your application. Interviewers want to verify that you understand the underlying mechanism (not just the workaround), that you know the migration path, and that you can articulate why asyncHandler exists and when it becomes unnecessary.

Model answer:

Express 4.x was designed before async/await was widespread in JavaScript. When Express invokes a route handler, it wraps the call in a synchronous try-catch:

// Simplified Express 4.x internals:
try {
  routeHandler(req, res, next);
  // If routeHandler is async, it returns a Promise HERE.
  // Express 4.x ignores the returned value entirely.
} catch (err) {
  next(err); // Only catches synchronous throws
}

When an async function is used as a handler, routeHandler(req, res, next) returns a Promise. Express 4.x does not inspect or attach .catch() to that Promise. If the Promise subsequently rejects (e.g., a database query fails), the rejection goes completely unhandled. The result: no error response is sent, the request hangs until the client times out, and Node.js emits an unhandledRejection warning.

To handle async errors in Express 4.x, there are three standard solutions:

  1. Manual try-catch in every async handler:
app.get('/api/users', async (req, res, next) => {
  try {
    const users = await User.find();
    res.json(users);
  } catch (err) {
    next(err);
  }
});
  1. asyncHandler utility that wraps the handler:
const asyncHandler = (fn) => (req, res, next) =>
  Promise.resolve(fn(req, res, next)).catch(next);

app.get('/api/users', asyncHandler(async (req, res) => {
  const users = await User.find();
  res.json(users);
}));
  1. express-async-errors package that monkey-patches Express globally:
require('express-async-errors'); // Require once at top of app.js
// All async errors are now caught automatically

Express 5.x (currently in beta) fixes this at the framework level. It automatically detects when a route handler returns a Promise and attaches a .catch(next) to it:

// Express 5.x — works without any wrapper
app.get('/api/users', async (req, res) => {
  const users = await User.find(); // If this rejects, Express 5 calls next(err)
  res.json(users);
});

What changes in Express 5: asyncHandler becomes unnecessary (but does not break anything if left in place), and express-async-errors becomes redundant.

What does NOT change in Express 5: error-handling middleware still needs 4 parameters; the centralized error handler still needs to be registered last; custom error classes like ApiError are still valuable; Mongoose/JWT error conversion is still needed; process-level handlers (uncaughtException, unhandledRejection) are still needed.

The migration from Express 4 to 5 is smooth because the centralized error handling architecture does not depend on asyncHandler -- it depends on errors reaching the error-handling middleware, which Express 5 ensures natively.


Q8. How does error handling work across middleware chains in Express?

Why interviewers ask: Real Express applications have complex middleware stacks -- authentication, rate limiting, validation, logging -- before the route handler ever executes. Interviewers want to verify you understand how errors propagate through these chains and how to handle errors at different layers without causing conflicts like double responses.

Model answer:

In Express, middleware executes in the order it is registered. When an error occurs at any point in the chain, it short-circuits the normal flow:

Normal: Parse Body → Auth → Rate Limit → Validate → Route Handler → Response
Error:  Parse Body → Auth (error) → SKIP SKIP SKIP → Error Handler → Response

There are several critical behaviors to understand:

1. next(err) skips all regular middleware. When any middleware calls next(err), Express skips every subsequent regular middleware (3-parameter functions) and route handler, jumping directly to the next error-handling middleware (4-parameter function).

2. Async middleware needs the same protection as route handlers. If your authentication middleware is async (e.g., it queries the database to look up a user), it needs asyncHandler or try-catch, exactly like a route handler:

const protect = asyncHandler(async (req, res, next) => {
  const token = req.headers.authorization?.split(' ')[1];
  if (!token) throw new ApiError(401, 'No token provided');

  const decoded = jwt.verify(token, process.env.JWT_SECRET);
  // If token is invalid, jwt.verify throws → caught by asyncHandler

  req.user = await User.findById(decoded.id);
  if (!req.user) throw new ApiError(401, 'User no longer exists');

  next(); // Everything OK, continue to the next middleware or route
});

3. Multiple error handlers can be chained. You can separate error handling concerns into distinct middleware. Each error handler receives the error and either sends a response or passes it to the next error handler:

// Step 1: Log the error (does not send response)
const logErrors = (err, req, res, next) => {
  console.error(`[${new Date().toISOString()}] ${req.method} ${req.url}`, err.stack);
  next(err); // Pass to the next error handler
};

// Step 2: Send the response
const sendErrorResponse = (err, req, res, next) => {
  res.status(err.statusCode || 500).json({
    success: false,
    message: err.message,
  });
};

app.use(logErrors);
app.use(sendErrorResponse);

4. Never send a response AND call next(err). If a middleware calls res.json() and then also calls next(err), the error handler will attempt to send a second response. This causes the dreaded "Cannot set headers after they are sent to the client" error. Always do one or the other:

// WRONG: double response
catch (err) {
  res.status(500).json({ error: 'Failed' });
  next(err); // Error handler tries to send ANOTHER response
}

// CORRECT: let the error handler do its job
catch (err) {
  next(err); // Only the error handler sends the response
}

5. Error handlers must be registered after all routes. If a route or middleware is registered after the error handler, errors from it have no error handler to fall through to.

6. The order of error handlers matters too. If you have multiple error handlers (e.g., a logger then a responder), they must be in the correct order. The logger must come first so it can log before the responder sends the final response.


Advanced Questions (Q9-Q11)

Q9. How would you implement graceful shutdown on uncaughtException and unhandledRejection?

Why interviewers ask: This question separates developers who have built toy projects from those who have operated Node.js in production. It tests your understanding of process-level error handling, the Node.js event loop's trust model, and deployment infrastructure concepts like process managers and health checks.

Model answer:

Node.js provides two process-level events for catching errors that escape all application-level handlers. They represent different levels of severity and must be handled differently.

process.on('uncaughtException') fires when a synchronous error is thrown and not caught by any try-catch -- for example, errors in setTimeout callbacks, event emitter errors, or bugs in startup code outside any handler.

After an uncaught exception, the Node.js process is in an undefined state. The event loop may be corrupted, memory may be in an inconsistent state, file handles may be leaked, and database connections may be half-open. You must exit the process immediately:

// Register at the VERY TOP of server.js, before any other code
process.on('uncaughtException', (err) => {
  console.error('UNCAUGHT EXCEPTION! Shutting down...');
  console.error(err.name, err.message);
  console.error(err.stack);

  // Exit immediately — the process state is unreliable
  process.exit(1);
});

There is no graceful shutdown here because you cannot trust the process to behave correctly. Attempting to finish existing requests or close database connections could produce corrupt data or hang indefinitely.

process.on('unhandledRejection') fires when a Promise rejects without a .catch() handler. This typically means an async error slipped past asyncHandler or try-catch. The process state is generally still reliable because the error was contained within a Promise chain.

Here you perform a graceful shutdown: stop accepting new connections, allow in-flight requests to complete, and then exit:

process.on('unhandledRejection', (reason, promise) => {
  console.error('UNHANDLED REJECTION! Shutting down...');
  console.error('Reason:', reason);

  // Graceful shutdown
  server.close(() => {
    process.exit(1);
  });

  // Safety timeout: if server.close() hangs, force exit after 10 seconds
  setTimeout(() => {
    console.error('Forced shutdown after timeout');
    process.exit(1);
  }, 10000).unref();
});

The graceful shutdown flow:

server.close()
  1. Stop accepting NEW connections
  2. Wait for existing in-flight requests to finish
  3. Close database connections, flush logs
  4. process.exit(1)
  5. Process manager (PM2, Docker, Kubernetes) restarts a new instance

Production considerations:

  • Process manager: Use PM2, Docker with restart policies, or Kubernetes with liveness probes to automatically restart crashed processes. The application itself should never try to self-heal after a critical error.
  • Safety timeout on server.close(): If a stuck connection prevents server.close() from completing, the process hangs forever. Add a setTimeout with .unref() to force exit after a reasonable timeout (e.g., 10 seconds).
  • Persistent logging: Use a logging service (Winston with file/remote transport, or Sentry) so crash details survive the process restart. Console output alone may be lost.
  • Health check endpoints: Your load balancer should hit a health check endpoint. If the process is shutting down, the health check fails, and the load balancer routes traffic to healthy instances.
  • Alert on every occurrence: Both events should trigger alerts (Slack, PagerDuty, email) because they indicate either a bug that needs fixing or a missing error handler that needs adding.

Q10. How should error handling differ between production and development environments?

Why interviewers ask: This tests your security awareness and operational maturity. Exposing stack traces and internal error details in production is a well-known vulnerability (CWE-209: Generation of Error Message Containing Sensitive Information). Interviewers want to see that you understand the trade-off between debuggability and security, and that you can implement environment-aware error responses.

Model answer:

Error responses must be environment-aware because development and production have fundamentally different priorities. Development optimizes for debuggability -- you want maximum information to fix bugs quickly. Production optimizes for security and user experience -- you want clean messages that help the user without exposing internals.

Development response (full details for debugging):

{
  "success": false,
  "status": "fail",
  "message": "Cast to ObjectId failed for value \"abc\" at path \"_id\"",
  "stack": "CastError: Cast to ObjectId failed...\n    at model.Query.exec (/app/node_modules/mongoose/lib/query.js:4913:21)\n    at /app/controllers/userController.js:25:30\n    at asyncHandler (/app/utils/asyncHandler.js:2:37)",
  "error": {
    "stringValue": "\"abc\"",
    "messageFormat": null,
    "kind": "ObjectId",
    "value": "abc",
    "path": "_id"
  }
}

This includes the full stack trace with file paths and line numbers, the raw error object with all properties from the originating library, and the original unprocessed message. A developer can look at this response and immediately know that line 25 of userController.js received an invalid ObjectId parameter.

Production response for operational errors (safe, user-facing message):

{
  "success": false,
  "status": "fail",
  "message": "Invalid _id: abc"
}

The centralized error handler has converted the raw Mongoose CastError into a clean, user-friendly message. No stack traces, no file paths, no library internals, no database field metadata.

Production response for programmer errors (generic, hides all internal details):

{
  "success": false,
  "status": "error",
  "message": "Something went wrong"
}

For non-operational errors (bugs), the client receives a completely generic 500 message. The actual error details are logged server-side with full context (stack trace, URL, HTTP method, request body, timestamp, user ID) for the development team to investigate.

Implementation in the centralized error handler:

const errorHandler = (err, req, res, next) => {
  let error = /* convert known types to ApiError */;
  error.statusCode = error.statusCode || 500;

  // Always log 5xx errors server-side with full context
  if (error.statusCode >= 500) {
    console.error('ERROR:', {
      message: err.message,
      stack: err.stack,
      url: req.originalUrl,
      method: req.method,
      timestamp: new Date().toISOString(),
    });
  }

  if (process.env.NODE_ENV === 'development') {
    return res.status(error.statusCode).json({
      success: false,
      status: error.status,
      message: error.message,
      stack: err.stack,
      error: err,
    });
  }

  // Production: isOperational errors show their message; bugs show generic
  if (error.isOperational) {
    return res.status(error.statusCode).json({
      success: false,
      status: error.status,
      message: error.message,
    });
  }

  return res.status(500).json({
    success: false,
    status: 'error',
    message: 'Something went wrong',
  });
};

Security concerns that this approach addresses:

  • Stack traces reveal file paths, directory structure, and line numbers -- valuable for attackers performing reconnaissance
  • Raw error objects can leak database field names, query structures, collection names, and library versions
  • Mongoose validation errors can reveal your schema structure and required fields
  • Environment variables or connection strings might appear in error messages from misconfigured libraries

One additional production practice: set NODE_ENV=production in your deployment environment. Forgetting this single environment variable means development error details are served to every user.


Q11. How would you design a consistent error response format for a REST API?

Why interviewers ask: API design is a senior-level skill that affects every client that consumes your API -- web frontends, mobile apps, third-party integrations. Interviewers want to see that you can design a contract that is consistent, machine-parseable, extensible for future needs, and follows industry conventions. A poorly designed error format creates friction for every team consuming the API.

Model answer:

A well-designed error response format must satisfy four requirements: consistency across all endpoints, machine-parseability for client error handling logic, human-readability for debugging, and extensibility for future needs.

Here is the structure I would use:

Standard error response:

{
  "success": false,
  "status": "fail",
  "message": "Validation failed: Name is required. Email must be a valid email address.",
  "errorCode": "VALIDATION_ERROR"
}

Validation error response (with field-level details):

{
  "success": false,
  "status": "fail",
  "message": "Validation failed: Name is required. Email must be a valid email address.",
  "errorCode": "VALIDATION_ERROR",
  "errors": [
    { "field": "name", "message": "Name is required" },
    { "field": "email", "message": "Email must be a valid email address" }
  ]
}

Design decisions and their rationale:

success: false -- A boolean flag that lets clients quickly determine if the request succeeded without inspecting the status code. Every successful response has success: true, every error has success: false. This is especially useful for clients that use a generic response handler.

status -- Either "fail" for 4xx client errors or "error" for 5xx server errors, following the JSend specification. This tells the client whether the problem is on their end (bad input, expired token) or on the server's end (database down, bug). Client code can show different UI patterns for each: "Please fix your input" vs "Please try again later."

message -- A single human-readable string summarizing the error. For validation errors, all field messages are concatenated. This is useful for displaying a single error banner in a UI or for logging. The message should be written for end users, not developers.

errorCode -- A machine-readable string constant like VALIDATION_ERROR, RESOURCE_NOT_FOUND, TOKEN_EXPIRED, DUPLICATE_RESOURCE, RATE_LIMIT_EXCEEDED. This is the most important field for programmatic error handling. Clients switch on this code rather than parsing the message string. This is critical for internationalization (i18n) -- the frontend maps the error code to a localized message in the user's language. It also prevents breakage when someone rewrites an error message.

errors array (optional, for validation errors) -- An array of field-level error objects, each with field and message. Frontend applications use this to highlight specific form fields with inline error messages. This is only present for validation errors; other error types omit it.

Additional considerations for production:

  • HTTP status codes must match the error semantics -- 400 for bad input, 401 for authentication, 403 for authorization, 404 for not found, 409 for conflicts, 429 for rate limiting, 500 for server errors. The JSON body supplements the status code, never contradicts it.
  • Rate limit errors (429) should include standard headers: X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, and optionally a Retry-After header.
  • Stack traces and internal details are included only in development responses, never in production.
  • The response shape must be identical across every endpoint. This is the primary advantage of centralized error handling -- one middleware enforces the format for the entire API, so client-side error handling code can be written once and reused.
  • Document the error codes. Every possible errorCode should be listed in your API documentation so frontend teams and third-party integrators know what to expect and how to handle each case.

The centralized error handler is the single enforcement point for this format. No matter where an error originates -- a route handler, a middleware, a Mongoose operation, a JWT verification -- the client always receives a response with the exact same shape. This consistency is what makes an API professional and easy to consume.


Quick-Fire Table

QuestionOne-Line Answer
What does next('route') do?Skips remaining handlers on the current route and passes control to the next matching route
Can you have multiple error handlers?Yes -- chain them with next(err) to pass errors from one handler to the next
What status code does Express default to for unhandled errors?500 Internal Server Error
Should you use res.json() or res.send() for API errors?res.json() -- ensures Content-Type is application/json and the body is parseable
What package eliminates the need for asyncHandler wrappers?express-async-errors -- require it once and all async rejections are caught globally

← Back to 3.17 — Error Handling in Express (README)