Cannot set headers after they are sent: your handler replied twice
The error is not really about headers. It is about a response that was already finished when your code tried to send another one, and the stack trace points at the symptom rather than the cause every single time.
Error [ERR_HTTP_HEADERS_SENT]: Cannot set headers after they are sent to the client is Node telling
you something slightly different from what it looks like. Nothing is wrong with your headers. Something in your
request path finished the response, and then something else tried to respond again.
What the error actually means
An HTTP response has an order: status line, then headers, then body. Once the first byte of the body is on the
wire, the headers are gone and cannot be changed. Node enforces that by throwing the moment you touch
res.status(), res.set(), res.json(), res.send(),
res.redirect() or res.render() on a response where res.headersSent is
already true.
So the error is a second response. Your real job is not to understand the line that threw. It is to find the first response, which completed successfully and left no trace at all.
This matters because the throw happens inside Node's own HTTP machinery, so the top frames of the stack are library code. The frame you care about is further down, and the frame that caused the problem is not in the stack at all, because it already returned.
Pattern 1: the missing return
By a wide margin the most common cause, and the easiest to miss in review. A guard clause responds but does not stop execution:
app.post('/orders', (req, res) => {
if (!req.body.sku) {
res.status(400).json({ error: 'sku required' }); // no return
}
// ...this still runs, and replies again
res.json(createOrder(req.body));
});
With a valid request it works perfectly. With an invalid one it sends 400, carries on, and throws on the second
res.json. Which is why it reaches production: the happy path is what gets tested.
The fix is return res.status(400).json(...). The habit worth building is to treat every
res. call that terminates a response as a return statement, every time, even as the last
line of a function where it makes no difference. Consistency is what makes the missing one visible.
Pattern 2: the async callback that outlived the response
A timeout fires, a promise settles late, a database callback lands after the request was already answered:
app.get('/report', async (req, res) => {
const timer = setTimeout(() => {
res.status(504).json({ error: 'timeout' });
}, 5000);
const data = await buildReport(); // takes 5.1s
clearTimeout(timer); // too late
res.json(data); // throws
});
Because it depends on timing, it reproduces only under load, which is exactly when you least want to be debugging it. These usually show up as a small, steady trickle of errors that correlates with p99 latency rather than with any particular route.
Use AbortController and have the slow operation cancel itself, or track completion with a flag that
both paths check. Do not reach for res.headersSent yet, for reasons in the last section.
Pattern 3: error handling that responds twice
A route catches an error, responds with 500, and also calls next(err). The error middleware then
responds too:
} catch (err) {
res.status(500).json({ error: 'internal' });
next(err); // error middleware replies again
}
Pick one owner for the response. The cleaner convention is that route handlers never serialise errors themselves:
they return next(err) and a single error middleware decides status, shape and logging. One place to
change when the error format changes, one place that can double-reply.
Pattern 4: writing after the stream closed
Streaming a file or an SSE connection and then trying to set a header on failure:
const stream = fs.createReadStream(path);
stream.pipe(res);
stream.on('error', () => {
res.status(500).send('read failed'); // body already flowing
});
Once pipe starts, the headers are gone. If the stream fails mid-flight there is no way to turn a
200 into a 500, because the client already has the 200. The honest handling is to destroy the response so the
client sees a truncated transfer rather than a corrupt file, and log the failure server-side:
stream.on('error', (err) => {
req.log.error({ err }, 'stream failed');
res.destroy(err);
});
If you need to be able to return an error, check the file is readable before you start piping.
Finding the real line
Since the first response leaves no stack trace, make it leave one. In development, wrap the response and record where it was finished:
app.use((req, res, next) => {
const end = res.end;
res.end = function (...args) {
if (res.$finishedAt) {
console.error('second response at', new Error().stack);
console.error('first response at', res.$finishedAt);
} else {
res.$finishedAt = new Error().stack;
}
return end.apply(this, args);
};
next();
});
That prints both call sites, which turns a twenty-minute hunt into a ten-second read. Keep it behind an environment check so it never ships.
In production, the practical substitute is correlation. Log a request id on every response and every error, and the double-reply shows up as two completion log lines for the same request id. If you already emit structured logs, this costs you nothing but a query.
The guard, and why it is not the fix
You will find this suggestion everywhere:
if (!res.headersSent) {
res.status(500).json({ error: 'internal' });
}
It is correct in exactly one place: inside your top-level error middleware, where a genuine late error can arrive for a request that was already answered and crashing the process is worse than swallowing it. There, the guard is defensive programming.
Anywhere else, it suppresses the symptom and keeps the bug. The request still took a path where two different pieces of code believed they owned the response, which means one of them ran logic it should not have: a second database write, a duplicate charge, a webhook fired twice. The error was the only thing telling you that, and the guard turns it off.
Fix the double-reply, then add the guard in one place as a backstop. In that order.
For what to do when the double-reply is one of a dozen errors arriving in the same hour and you need to triage rather than fix, see getting the real error out of a 500.
Found something wrong in here? Tell me.