Fix this error · Servers and hosting
Fix: Error [ERR_HTTP_HEADERS_SENT]: Cannot set headers after they are sent to the client
Why Express throws Cannot set headers after they are sent: missing return, double next(), async handlers in Express 4 and 5, with fixes.
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
asynchandler on Express 4, the error becomes an unhandled promise rejection. On Node 15 and later, that stops the process by default. - In an
asynchandler on Express 5, Express passes the rejected value to your error handlers. - In a callback such as
setTimeoutor an event listener, it is an uncaught exception, which also stops the process.
Common causes
- No
returnafter sending. A guard clause sends a 404, then the code below it sends the normal response. - Calling
next()after sending. Middleware sends a response and also callsnext(), so a later handler sends again. - Sending inside a loop or callback.
res.json()insideforEach, or once in a callback and once after it. - An error handler that answers a request that already has a response. The handler calls
res.status(500)without checkingres.headersSent. - Two code paths in async code. A
.then()sends the result and a.catch()or code after the promise sends an error. - 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
// 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
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
// 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.
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:
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:
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:
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.
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 and lib/internal/errors.js (message text), checked 2 Nov 2026.
- Express: Error handling (
res.headersSent, async handlers in Express 5), checked 2 Nov 2026. - Express: Migrating to Express 5 (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:
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:
500: INTERNAL_SERVER_ERROR Code: FUNCTION_INVOCATION_FAILED - CORS: No 'Access-Control-Allow-Origin' header:
has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource. - Vercel FUNCTION_INVOCATION_TIMEOUT:
504: GATEWAY_TIMEOUT Code: FUNCTION_INVOCATION_TIMEOUT - Postgres too many clients already:
FATAL: sorry, too many clients already
All guides: Fix an error.
Start free. Free plan · No credit card · Set up in minutes. Or try the live demo first.