← Recipes

阅读中文版本

Add a Request Timeout Wrapper

Enforce a per-request deadline in Node.js, propagate cancellation to downstream calls, and return a clear 503 instead of holding connections open.

~20 minnodejshttpreliability

Task

Add a timeout wrapper that aborts slow work, returns 503 when the deadline expires, and does not leave downstream requests running.

When to use this

An Express service calls a slow dependency. Without a deadline, requests pile up, the connection pool fills, and a single slow dependency degrades every route.

Prerequisites

  • Node.js 20 or newer with AbortController
  • An Express or similar HTTP framework
  • Access to the route handlers that perform downstream I/O

Steps

1. Create a wrapper that starts a timer, passes an AbortSignal to the handler, and maps an aborted request to 503.

function withTimeout(handler, timeoutMs = 2000) {
  return async (req, res, next) => {
    const controller = new AbortController();
    const timer = setTimeout(() => controller.abort(), timeoutMs);
    try {
      await handler(req, res, controller.signal);
    } catch (error) {
      if (controller.signal.aborted && !res.headersSent) {
        res.status(503).json({ error: 'request timeout' });
        return;
      }
      next(error);
    } finally {
      clearTimeout(timer);
    }
  };
}

Expected output: The wrapper returns 503 if the handler does not finish within the deadline.

2. Pass the signal into every downstream call so the work is cancelled, not just the response.

app.get('/report', withTimeout(async (req, res, signal) => {
  const upstream = await fetch('https://reports.internal/summary', { signal });
  const data = await upstream.json();
  res.json(data);
}, 1500));

Expected output: When the timer aborts, the fetch rejects with an AbortError and the upstream request is cancelled.

3. Test with a route that sleeps longer than the timeout, then verify the response status and that the process does not log an unhandled rejection.

curl -i http://localhost:3000/report

Expected output: HTTP/1.1 503 Service Unavailable within roughly the configured timeout.

Troubleshooting

Problem

The client receives 503 but the database query keeps running

Solution

The handler is not passing the signal to the database driver. Propagate the signal to every I/O call that supports cancellation.

Problem

The server logs "headers already sent"

Solution

The handler writes a response after the timeout has already sent 503. Check res.headersSent before writing.

Limitations

  • A timeout cannot cancel work that ignores the signal; use driver-level timeouts as well.
  • The wrapper does not replace an end-to-end timeout budget across multiple services.

Why a response timeout is not enough

Returning 503 while a database query continues to run protects the client but not the server. The query still holds a connection and consumes memory. A useful timeout has two parts: a deadline that controls the response, and cancellation that reaches the downstream operation. The wrapper above handles both when the handler accepts the signal.

Propagate the signal

Pass the signal to fetch, database drivers, and queue clients. For libraries without cancellation support, set a library-level timeout and document the gap. Do not start new work after the signal aborts; check signal.aborted before expensive steps.

if (signal.aborted) throw new Error('request cancelled');

Verify the failure path

Add a test route that waits longer than the timeout and assert that the response is 503, the handler stops, and no unhandled rejection appears in the logs. Also test a fast route to confirm the timer is cleared and does not keep the process alive. A timeout that leaks timers can prevent a clean shutdown.