# Fix: Error [ERR_HTTP_HEADERS_SENT]: Cannot set headers after they are sent to the client

Your Express handler sent a response and then tried to send another one, or to set a header, for the same request. Node allows one response per request, so the second call throws. The fix that works most often is to `return` after every `res.send()`, `res.json()`, `res.redirect()` or `next()`, so the code after it does not run.

| Fact | Detail |
|---|---|
| Error | `Error [ERR_HTTP_HEADERS_SENT]: Cannot set headers after they are sent to the client` |
| Error code | `ERR_HTTP_HEADERS_SENT` |
| Where you see it | Server logs, the terminal running Node |
| Usual cause | A missing `return` after `res.json()`, so a second `res.json()` runs |
| Usual fix | `return res.json(...)`, and check `res.headersSent` in error handlers |
| BugTape catches it | Yes, with the Node SDK: the Express error handler, or an unhandled rejection in Express 4 async handlers |

## What the error means

An HTTP response starts with a status line and headers. Once Express has written them, they cannot change. Any later call that sets a status or a header, such as `res.status()`, `res.json()`, `res.send()`, `res.redirect()`, `res.cookie()` or `res.setHeader()`, throws `ERR_HTTP_HEADERS_SENT`.

The client usually got the first response, often with status 200, so the bug can go unnoticed for a long time. What happens on the server depends on where the second call runs:

- In a synchronous handler, Express catches the error and passes it to your error handlers.
- In an `async` handler on Express 4, the error becomes an unhandled promise rejection. On Node 15 and later, that stops the process by default.
- In an `async` handler on Express 5, Express passes the rejected value to your error handlers.
- In a callback such as `setTimeout` or an event listener, it is an uncaught exception, which also stops the process.

## Common causes

1. **No `return` after sending.** A guard clause sends a 404, then the code below it sends the normal response.
2. **Calling `next()` after sending.** Middleware sends a response and also calls `next()`, so a later handler sends again.
3. **Sending inside a loop or callback.** `res.json()` inside `forEach`, or once in a callback and once after it.
4. **An error handler that answers a request that already has a response.** The handler calls `res.status(500)` without checking `res.headersSent`.
5. **Two code paths in async code.** A `.then()` sends the result and a `.catch()` or code after the promise sends an error.
6. **A timeout middleware.** The middleware sends a 503 after a timeout, then the slow handler finishes and sends its own response.

## How to fix it

### Return when you send

```js
// Before: sends 404, then also tries to send the user
app.get('/users/:id', async (req, res) => {
  const user = await db.user.findUnique({ where: { id: req.params.id } });
  if (!user) {
    res.status(404).json({ error: 'not_found' });
  }
  res.json(user);
});

// After
app.get('/users/:id', async (req, res) => {
  const user = await db.user.findUnique({ where: { id: req.params.id } });
  if (!user) {
    return res.status(404).json({ error: 'not_found' });
  }
  return res.json(user);
});
```

### Call next() or send, never both

```js
function requireAuth(req, res, next) {
  if (!req.headers.authorization) {
    return res.status(401).json({ error: 'unauthorized' });
  }
  return next();
}
```

Call `next()` once per request. Calling it twice, or calling `next(err)` after `res.send()`, runs another handler for the same request.

### Send once, after the loop

```js
// Before: res.json inside forEach runs once per item
items.forEach((item) => {
  if (!item.valid) res.status(400).json({ error: 'invalid', id: item.id });
});
res.json({ ok: true });

// After
const invalid = items.find((item) => !item.valid);
if (invalid) return res.status(400).json({ error: 'invalid', id: invalid.id });
return res.json({ ok: true });
```

### Check res.headersSent in error handlers

Express's docs say a custom error handler must hand over to the default handler when the headers have already been sent. The default handler then closes the connection.

```js
app.use((err, req, res, next) => {
  if (res.headersSent) {
    return next(err);
  }
  res.status(500).json({ error: 'internal_error' });
});
```

### Handle errors from async handlers (Express 4)

Express 4 does not catch a rejected promise from an `async` handler. Catch it and pass it to `next`:

```js
app.get('/orders', async (req, res, next) => {
  try {
    const orders = await listOrders(req.user.id);
    return res.json(orders);
  } catch (err) {
    return next(err);
  }
});
```

Express 5 does this for you: "Route handlers and middleware that return a Promise call `next(value)` automatically when they reject or throw an error." Upgrading to Express 5 removes the crash, but the double send is still a bug to fix.

### Stop the handler after a timeout

If a timeout middleware may already have answered, check before sending:

```js
app.post('/export', async (req, res) => {
  const file = await buildExport(req.body);
  if (res.headersSent) return; // the timeout already answered
  return res.json(file);
});
```

## How to catch it in production with BugTape

Add the Node SDK and its Express error handler after your routes and before your own error handler:

```js
import * as bugtape from '@bugtape/node';

bugtape.init({ service: 'api', release: process.env.GIT_SHA });

app.use(routes);
app.use(bugtape.expressErrorHandler());
app.use((err, req, res, next) => {
  if (res.headersSent) return next(err);
  res.status(500).json({ error: 'internal_error' });
});
```

The BugTape handler reports errors that end in a 5xx and skips 4xx errors such as 404. `ERR_HTTP_HEADERS_SENT` has no HTTP status of its own, so it is treated as a 500 and reported, with the method and the route pattern, such as `GET /users/:id`. The error is passed on unchanged, so your own handler still runs. The handler only sees errors that reach Express's error chain: synchronous throws, `next(err)`, and async rejections on Express 5.

On Express 4, the same error in an `async` handler is an unhandled rejection, and the SDK reports it as one. When BugTape's listener is the only one, it reports the error, waits up to 2 seconds for delivery, then lets Node stop the process as it would have without BugTape. If you run Node with `--unhandled-rejections=warn` or `none`, or your app has its own `unhandledRejection` listener, call `bugtape.captureException(reason)` in that listener.

The browser SDK will usually not report this error. The client received the first response, often a 200, so there is no failed request to see.

Repeats from the same route group into one issue with a count. Set `release` so a fix that does not hold reopens the issue as a regression. Your coding agent can read the issue over MCP (`npx -y bugtape connect claude`, or `cursor` or `codex`) and propose the missing `return`. You review the pull request. Server reports have no replay or console output.

## FAQ

### Why does the client get a 200 when the server logs an error?

The first `res.json()` or `res.send()` already sent the status, headers and body. The error comes from the second call, after the response left. The client never sees it, so the bug shows up only in server logs or an error tracker.

### Does Express 5 fix "Cannot set headers after they are sent"?

Express 5 changes what happens after the error. It forwards errors from async handlers to your error handlers, so the process does not stop. The double send still happens and still needs a `return` or a single response path.

### Can a missing return crash my server?

Yes, in an `async` handler on Express 4. The second send throws inside a promise, nothing catches it, and Node 15 and later stop the process on an unhandled rejection by default. See [unhandled promise rejection](/guides/node-unhandled-promise-rejection/).

### How do I find which line sends the second response?

The stack trace in the error points at the second call. Search the handler for every `res.` call and every `next(` call, and check that each path reaches exactly one of them. Logging `res.headersSent` before each send also shows where the first response went out.

## Sources

- [Node.js errors: ERR_HTTP_HEADERS_SENT](https://nodejs.org/api/errors.html#err_http_headers_sent) and [lib/internal/errors.js](https://github.com/nodejs/node/blob/main/lib/internal/errors.js) (message text), checked 2 Nov 2026.
- [Express: Error handling](https://expressjs.com/en/guide/error-handling.html) (`res.headersSent`, async handlers in Express 5), checked 2 Nov 2026.
- [Express: Migrating to Express 5](https://expressjs.com/en/guide/migrating-5.html) (rejected promises handled), checked 2 Nov 2026.
- Behaviour of Express 4.22.3 and 5.3.0 on Node.js 25, reproduced locally, 2 Nov 2026.

## Related errors

- [Node.js unhandled promise rejection](/guides/node-unhandled-promise-rejection/): `This error originated either by throwing inside of an async function without a catch block, or by rejecting a promise which was not handled with .catch()`
- [Vercel FUNCTION_INVOCATION_FAILED](/guides/vercel-function-invocation-failed/): `500: INTERNAL_SERVER_ERROR Code: FUNCTION_INVOCATION_FAILED`
- [CORS: No 'Access-Control-Allow-Origin' header](/guides/cors-blocked-no-access-control-allow-origin/): `has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.`
- [Vercel FUNCTION_INVOCATION_TIMEOUT](/guides/vercel-function-invocation-timeout/): `504: GATEWAY_TIMEOUT Code: FUNCTION_INVOCATION_TIMEOUT`
- [Postgres too many clients already](/guides/postgres-too-many-clients/): `FATAL: sorry, too many clients already`

All guides: [Fix an error](/guides/).

[Start free](/console/register?utm_source=bugtape_seo&utm_campaign=error-guides). Free plan · No credit card · Set up in minutes. Or [try the live demo](/demo/?from=landing&start=1#capture) first.
