Skip to content
AITroveRead. Build. Understand.
Make this comfortable

Express 5 Async Errors and Response Contracts

Last updated: 5 Oct 20268 min read
tutorial
IntermediateBy AITrove Editorial

Express 5 routes and middleware that return rejected promises pass those rejections to the error handler. That is different from an unreturned promise started in the background; the router cannot catch work it does not own. A response also has a one-way boundary. Before headers are sent, a centralized handler can choose a status and safe body. After streaming headers are sent, it cannot replace a partial response with a new JSON error. Client-facing errors should separate malformed input, denied access, missing resources, conflicts, and internal failures without exposing service traces. A route should send exactly one response or call next exactly once for its error path.

Working case

A permit approval handler starts an async repository update without awaiting or returning it, sends 202, and later throws when the database rejects. The caller believes the case was approved. Another handler catches an error, calls next(error), and then sends a 500 body; the centralized handler also tries to send, producing a headers-sent failure. The repaired handler awaits one operation, maps expected domain outcomes to 400, 403, 404, or 409, and leaves unexpected rejection to Express 5's error path. The final handler logs a correlation ID and returns a generic 500 only when headers remain unsent. An already streaming response is delegated for connection handling.

Implementation boundary

javascript
import express from 'express';
import { permitService, reportError } from './services.js';

const app = express();
app.post('/api/permits/:caseId/approve', async (request, response) => {
  const result = await permitService.approve(request.reviewer.id, request.params.caseId);
  if (result.kind === 'forbidden') return response.sendStatus(403);
  if (result.kind === 'missing') return response.sendStatus(404);
  if (result.kind === 'conflict') return response.status(409).json({ message: 'Case changed' });
  response.json({ caseId: result.caseId, status: result.status });
});
app.use((error, request, response, next) => {
  reportError(error, request);
  if (response.headersSent) return next(error);
  response.status(500).json({ message: 'Request failed' });
});

Target the actual Express major version in the application lockfile; do not assume automatic promise forwarding in an older deployment. In Express 5, an async route should await its database or service call and either send its result or throw. Do not launch a detached promise for a request-critical write. Map expected service results locally, because a domain conflict is not an internal exception. In the final error middleware, keep all four parameters so Express recognizes it, log internal context separately, and check headersSent before setting a new status. When a response stream has begun, propagate the error to the default handler or close the stream according to the transport contract. Avoid exposing stack traces, tokens, or private case titles in the client body.

Cost and boundaries

One central error mapper reduces repeated code but adds a shared dependency to every route. Logging full error objects can leak sensitive payloads and consume space under an incident; redact before writing. Returning 202 before a critical write finishes can make latency look low while moving the failure outside the request contract. Waiting for a committed result adds response time, but it gives the client an honest outcome. A streamed response saves time to first byte yet narrows recovery choices once headers have left. Measure rejected promise count, double-send attempts, partial responses, and time to confirmed mutation, not only nominal status codes.

Failure trace

Force a repository rejection before the response and confirm one generic 500 with a logged correlation ID. Return a domain conflict and confirm 409 without a stack trace. Start a detached promise as a negative test and show that its rejection escapes the route; repair by awaiting it. Throw after a streamed first chunk and verify the server does not attempt a second JSON body. Call next twice in a test middleware and watch for duplicate error handling, then remove the second call. Validate that a 403 response does not reveal whether a forbidden private case exists if the API's disclosure rule forbids that information.

Verification

  • A rejected async route reaches one error handler.
  • Expected domain outcomes have distinct controlled statuses.
  • No second body is sent after headers have left.

Practice drill

Implement an approval route in an Express 5 test app with a fake service returning approved, invalid, forbidden, missing, and conflict states. Add one injected unexpected rejection. Write expected status and response body for each case before coding. Then stream a diagnostic export, fail after the first chunk, and inspect the connection behavior. Verify the four-argument error handler and headersSent path. Repeat one test with an unreturned promise to demonstrate why request-critical work must remain inside the returned async handler.

Decision note

Await request-critical work, map domain outcomes explicitly, and let one error path own unexpected failures.

Common Mistakes

  • Starting request-critical work without awaiting or returning it.
  • Calling next(error) and also writing a response.
  • Assuming an older Express version forwards rejections the same way.

Related lessons

Express Request and Process Boundaries; Express Middleware Order and Request Identity; Node Request Budgets, Abort, and Event Loop Fairness; Node HTTP Drain, Readiness, and Graceful Shutdown; API Mutation and Failure Contracts; Streaming and Large Data Interfaces.

Apply and check

Build Project: Express permit API lifecycle and review Web Development: Express request and process quiz.

web-tech
web-development
Storage details