ERR_HTTP_HEADERS_SENT: Cannot set headers after they are sent (tested)
Error [ERR_HTTP_HEADERS_SENT]: Cannot set headers after they are sent to the client means your code tried to respond twice to one request. The first response already went out. We reproduced seven common causes in Express 5.2.1 on Node 24 and measured what the server logs, what the client receives, and when the whole process dies.
1. What the error means
An HTTP response has one status line and one set of headers. Once Node has sent them, they cannot change. Any later call that sets a header throws this error. In Express that includes res.send(), res.json(), res.status().send(), res.redirect() and res.setHeader().
The top of the stack is always the same. This is the exact output we got from a second res.send():
Error [ERR_HTTP_HEADERS_SENT]: Cannot set headers after they are sent to the client
at ServerResponse.setHeader (node:_http_outgoing:684:11)
at ServerResponse.header (.../express/lib/response.js:686:10)
at ServerResponse.send (.../express/lib/response.js:163:12)
at /tmp/gheaderssent/app.js:9:7
The first line under express/lib/response.js that points into your file is the second response. That is the line to look at. The first response happened earlier and is not in the stack.
2. Results: seven ways to trigger it
| Cause | Client received | Server |
|---|---|---|
Missing return after res.status(400).send() | 400 "name required", complete | Stack logged, process kept running |
res.json() inside forEach | 200 {"i":1} only | Stack logged once (the loop stops at the throw) |
| Callback and promise both respond | 200 "promise-data" | Process crashed, exit code 1 |
next() after res.send() | 200 "done" | Stack logged from the next handler's res.send |
res.setHeader() after res.write() | Nothing: curl (52) Empty reply from server; fetch "fetch failed", cause UND_ERR_SOCKET "other side closed" | Stack logged, socket destroyed |
res.redirect() after res.send() | 200 "ok", no redirect | Stack logged, top frames res.location / res.redirect |
Second res.send() after await in an async handler | 200 "ok" | Stack logged, process kept running |
Most of the time the client gets the first response and never sees an error. That is why this bug hides in production: users get a 200 or 400 and only your logs show the stack.
3. Does it crash the process?
It depends on where the second response is called from. We ran each case with no process.on('uncaughtException') handler:
- Synchronous handler: Express catches the throw. The process stays up.
- Async handler (
async (req, res) => ...): Express 5 catches the rejected promise and passes it to error handling. The process stays up. Per the Express docs (not measured), Express 4 did not do this. - Timer or callback (
setTimeout, old-stylecb(err, data)APIs, event emitters): nothing catches it. Node printed the error and exited with code 1. The next curl got(7) Failed to connect. - A
.then()chain the handler does not return: also crashed (exit code 1). The rejection is unhandled because Express never saw the promise.
When Express does catch it, its default error handler (finalhandler 2.1.1) prints the stack with console.error and then, because headers were already sent, calls req.socket.destroy(). If the first response was complete, the client is fine. If it was a half-written stream, the client gets a cut-off or empty reply. That is what happened in the res.write() case: the buffered "part" was never flushed, so curl got an empty reply.
4. Fix: return when you respond
The most common cause is a guard clause without return:
// Bug: falls through to the second res.send
app.get('/hello', (req, res) => {
if (!req.query.name) { res.status(400).send('name required'); }
res.send('hello ' + req.query.name);
});
// Fix
app.get('/hello', (req, res) => {
if (!req.query.name) { return res.status(400).send('name required'); }
res.send('hello ' + req.query.name);
});
Verified: the fixed route returned 400 without a name and 200 "hello a" with one, and logged nothing.
Apply the same rule to if/else chains, try/catch (a catch that responds after the try already did), and middleware: once you send, do not call next(). Our next-fixed route (send, no next()) logged nothing.
5. Fix: one response per request
- Loops. Build the data first, send once:
res.json(items.map(i => ({ i }))). Verified: 200[{"i":1},{"i":2},{"i":3}], no error. - Callback plus promise. Pick one style. We used
const d = await legacy(); res.send(d);and passed a no-op callback. Verified: 200 "promise-data", process stayed up. - Redirect. Use
return res.redirect('/login')instead of sending a body first. A redirect is the response. - Headers with streaming. Set every header before the first
res.write(). Verified: settingX-Latebeforewritereturned it in the response with no error.
6. Guard with res.headersSent
res.headersSent is true once the status line and headers are on the wire. Use it when a late path really cannot know if someone else already answered, such as a timeout handler racing the real work:
app.get('/report', async (req, res) => {
res.send('ok');
await somethingSlow();
if (!res.headersSent) res.send('again'); // skipped
});
Verified: the guard logged headersSent= true and skipped the second send. No error.
Do not use it to paper over a real double response. A missing return is better fixed with a return.
Custom error handlers need the same check. Express's docs say to delegate to the default handler when headers are already sent:
app.use((err, req, res, next) => {
if (res.headersSent) return next(err);
res.status(500).send('error');
});
Without that check, your handler's own res.status(500).send() throws a second ERR_HTTP_HEADERS_SENT. With it, our handler saw headersSent= true for every case above and handed off cleanly.
7. Errors thrown after the response
An error that happens after you already answered is not ERR_HTTP_HEADERS_SENT, but it lands in the same place. We sent "ok", awaited 10 ms, then threw new Error('db write failed'). The client got 200 "ok". Express 5 forwarded the error, our error handler saw headersSent= true, and the default handler logged Error: db write failed. The process kept running. The client never learns the write failed, so do slow work before you respond, or use a queue.
If the client gets a 500 instead, see 500 Internal Server Error. If Express answers Cannot GET /path, the route did not match at all: see Cannot GET in Express.
8. Checklist
- Read the stack: the first frame in your code is the second response.
- Put
returnin front of everyres.send/json/redirect/renderinside a condition. - No
res.*calls inside loops. - Do not mix callbacks and promises for one result. Await, then respond once.
- Never call
next()after sending. - Set headers before
res.write(). - Error handlers start with
if (res.headersSent) return next(err);. - Late errors in timers and callbacks crash the process. Keep them inside an
asynchandler so Express 5 can catch them.
Frequently asked questions
Why does the client get a normal response when the server logs this error?
Because the first response already went out complete. In our tests the client got the first 400 or 200 every time the first response was a full res.send(). Only the second call fails, and only the server sees it.
Does ERR_HTTP_HEADERS_SENT crash Node?
Not in a sync or async Express 5 route: Express catches it and logs the stack. It did crash our process (exit code 1) when the second send ran in a setTimeout callback or in a .then() chain the handler did not return.
How do I find which line sends the second response?
Look at the stack below express/lib/response.js. The first frame in your own file is the second res.send, res.json or res.redirect. The first response is earlier in the same request path.
Should I check res.headersSent before every send?
No. Fix the control flow with return first. Use res.headersSent where two paths race, like a timeout and the real work, and always in custom error handlers: if (res.headersSent) return next(err).
Why did my client get an empty reply?
If headers or part of a body were already written when the error happened, Express's default handler destroys the socket. In our res.write() test curl reported (52) Empty reply from server and fetch failed with UND_ERR_SOCKET other side closed.
Written with AI assistance. Measured on 2026-10-02 with Express 5.2.1 (finalhandler 2.1.1), Node.js 24.21.0, curl 8.14.1 and Node fetch. Every bug and every fix below was run as a real route and hit with curl. PlainHeaders is our extension; Express, Node.js and curl are free.