Fix "Cannot Set Headers After They Are Sent" (Node/Express)
It almost always means one handler tried to respond twice. Here is the mental model, the six patterns that cause it, and the fixes — starting with the missing return that catches nearly everyone.
Error [ERR_HTTP_HEADERS_SENT]: Cannot set headers after they are sent to the client is the backend counterpart to the frontend's most-hated crashes: alarming to read, but nearly always caused by one small structural mistake. It does not mean your headers are malformed or your client is misbehaving. It means a single handler tried to answer the same request twice, and Node refused the second answer.
What the error actually means
ERR_HTTP_HEADERS_SENT is thrown when your code writes to an HTTP response after it has already been sent. An HTTP response has one status line and header block, flushed before the body; once that happens the response is committed and closed. A second res.send(), header set, or redirect has no open response to modify, so Node throws.
That ordering is not a Node quirk — it is how HTTP itself is framed on the wire. Per MDN's reference on HTTP messages, a response is a start-line (the status), then a set of headers, then an empty line marking "metadata complete," and only then an optional body. Headers are physically transmitted before the body, so once a single byte of body has gone out, there is no going back to add or change a header. Node enforces exactly this: the http docs state that response.headersSent becomes true once headers are sent and that they then "become immutable and cannot be changed." The official error reference defines ERR_HTTP_HEADERS_SENT as "an attempt was made to add more headers after the headers had already been sent."
The mental model is worth internalizing because it makes every variant of this bug obvious. Sending an HTTP response is a one-way door. res.send(), res.json(), res.end(), and res.redirect() all walk through it: they set the status, flush the headers, write the body, and close the connection. Note that even res.write() counts — Node's docs confirm that if writeHead() was not called first, the first write() flushes the headers automatically, tripping the same one-way latch. After that door closes, the response object still exists in memory, but it is inert — every method that would write to it now throws instead. So the question is never "why are my headers broken?" It is always "which second write ran after the first response?"
The six patterns that cause it
The examples below are Express-centric because that is where most people hit this, but the exact same rule applies to the raw Node http module: after res.writeHead() or res.end(), the response is committed and any further write throws.
1. Forgetting to return after responding — the most common cause by a wide margin. Calling res.json() sends the response but does not stop your function; the next line still runs.
// BROKEN: res.json() sends, but execution keeps going
app.get('/user/:id', async (req, res) => {
const user = await db.findUser(req.params.id);
if (!user) {
res.status(404).json({ error: 'Not found' });
// no return — code falls through to the line below
}
res.json(user); // runs even when user was null → throws
});
// FIXED: return exits the handler the moment you respond
app.get('/user/:id', async (req, res) => {
const user = await db.findUser(req.params.id);
if (!user) {
return res.status(404).json({ error: 'Not found' });
}
return res.json(user);
});2. Responding inside a loop or a callback that runs more than once. The first iteration responds; the second one throws.
// BROKEN: res.json() runs once per matching item
app.get('/search', (req, res) => {
items.forEach((item) => {
if (item.match(req.query.q)) {
res.json(item); // throws on the 2nd match
}
});
});
// FIXED: collect, then respond exactly once
app.get('/search', (req, res) => {
const matches = items.filter((item) => item.match(req.query.q));
return res.json(matches);
});3. Responding in both a branch and after it — a missing else or early return. Structurally identical to pattern 1, but worth calling out because it hides inside longer if/else if chains where the fall-through is less obvious.
// BROKEN: the success case sends, then the tail sends again
app.post('/login', (req, res) => {
if (isValid(req.body)) {
res.json({ token: sign(req.body) });
}
res.status(401).json({ error: 'Invalid credentials' }); // always runs
});
// FIXED: one response per path, guaranteed by returns
app.post('/login', (req, res) => {
if (isValid(req.body)) {
return res.json({ token: sign(req.body) });
}
return res.status(401).json({ error: 'Invalid credentials' });
});4. An async race — a callback and a timeout (or two promises) both respond. This one is nastier because it may only fail intermittently, under load, when the timing lines up.
// BROKEN: both the timeout and the query can respond
app.get('/slow', (req, res) => {
const timer = setTimeout(() => {
res.status(504).json({ error: 'Timed out' });
}, 3000);
db.query().then((rows) => {
res.json(rows); // if the timeout already fired, this throws
});
});
// FIXED: clear the timer and guard so exactly one path wins
app.get('/slow', (req, res) => {
let done = false;
const finish = (fn) => { if (!done) { done = true; fn(); } };
const timer = setTimeout(() => {
finish(() => res.status(504).json({ error: 'Timed out' }));
}, 3000);
db.query().then((rows) => {
clearTimeout(timer);
finish(() => res.json(rows));
});
});5. Middleware that calls next() and also sends a response. Express middleware has one job per invocation. As the Express guide on writing middleware puts it: "If the current middleware function does not end the request-response cycle, it must call next() to pass control to the next middleware function. Otherwise, the request will be left hanging." The word to notice is either — end the cycle by responding, or pass control with next(), never both. Doing both means your middleware responds and then a downstream handler responds again.
// BROKEN: responds AND calls next() → the route responds too
function requireAuth(req, res, next) {
if (!req.user) {
res.status(401).json({ error: 'Unauthorized' });
}
next(); // runs even after the 401 above
}
// FIXED: return after responding; only call next() to continue
function requireAuth(req, res, next) {
if (!req.user) {
return res.status(401).json({ error: 'Unauthorized' });
}
return next();
}6. Responding in an error handler after a response was already sent. If a route streams a partial response and then throws, Express hands the error to your error-handling middleware — but the headers are already gone. This is the one case where you genuinely cannot restructure your way out, and where you should check res.headersSent.
Guarding error handlers with res.headersSent
res.headersSent is a boolean on the response object: false until the headers are flushed, true afterward. Reading it lets you decide whether it is still safe to respond. The canonical use is the Express error handler. Express's own error-handling guide is explicit that you must delegate to the default handler once headers are out: if you call next(err) after the response has started, the default handler closes the connection and fails the request cleanly — whereas your custom handler trying to res.status(500).json(...) would throw a second, more confusing error that masks the original one.
// The four-arg signature marks this as Express error middleware
app.use((err, req, res, next) => {
// If a partial response already went out, don't write again —
// hand off to Express's built-in handler, which closes the socket.
if (res.headersSent) {
return next(err);
}
res.status(500).json({ error: 'Something went wrong' });
});Cause → fix at a glance
Every row below is the same underlying defect — a second write to a finalized response — but the symptom and the smallest correct fix differ. Use this as a triage table: match the shape of your bug on the left, apply the fix on the right.
| Feature | What triggers it | The minimal fix |
|---|---|---|
| Missing return after responding | res.json() sends, but execution falls through to another response | Prefix with return: return res.json(...) |
| Responding inside a loop / repeated callback | forEach or an emitter fires the response more than once | Collect first, then respond exactly once after the loop |
| Branch plus fall-through | A success branch responds, then a shared tail responds too | Give each branch its own return res... — no shared tail |
| Async race (timeout vs. query) | Two callbacks each respond when timing lines up | Single-flight guard (a done flag) + clearTimeout() |
| Middleware calls next() and responds | Auth guard sends 401 but still calls next() | return res.status(401)...; only call next() to continue |
| Error handler after a partial response | Route streams, then throws; handler writes again | if (res.headersSent) return next(err) |
The async angle, and what Express 5 changes
Most stubborn cases of this error live in asynchronous code, because that is where two response paths can exist without being visible on the same screen. The durable fix for async handlers is the standard Express pattern: await your work inside a try block, respond once on success, and in catch forward the error with next(error) rather than responding a second time. Per Better Stack's Express error-handling guide, wrapping async operations in try/catch and forwarding with next(error) is what keeps a rejected promise from either crashing the process as an unhandled rejection or racing your success path to the response.
Express 5 tightens this considerably. Better Stack notes that Express 5 "improves error handling by automatically propagating errors from async functions to the error handler," so an error thrown inside an async route is caught for you and the explicit next(error) call is no longer required. That removes one common way to accidentally double-respond — but it does not remove the others. A missing return, a middleware that both sends and calls next(), or a racing timeout will still throw ERR_HTTP_HEADERS_SENT on Express 5 exactly as on Express 4. The framework can catch your thrown errors; it cannot decide which of your two responses you meant.
Caveats — when this isn't the whole story
A few honest limits. First, res.headersSent tells you headers are gone, but it cannot un-send a partial body — by the time it reads true, some bytes may already be on the wire, so a streamed download that fails midway can leave the client with a truncated response no guard can repair. The guard prevents a second thrown error; it does not make the first response whole. Second, the missing-return habit is necessary but not sufficient: a handler can still double-respond across two separate async callbacks that each own a valid response path, and no amount of return in one of them constrains the other. Third, frameworks layered on Express — or your own response helpers that call res.send() internally — can hide the real second write, so a stack trace that points at library code usually means the offending call is a level up in your code. As the Honeybadger guide to Node.js error handling stresses, centralizing response and error logic in one place beats scattering guards, precisely because it makes "exactly one response path" something you can see rather than hope for.
The one rule that prevents all six
Every pattern above is the same mistake wearing a different costume: two writes to one response. So the fix is a single design rule — each request handler must have exactly one response path, and it must return the moment it responds. In practice that means three habits. First, prefix every response call with return, so responding and exiting are one action. Second, in branching code, make each branch responsible for its own return res... rather than letting execution fall through to a shared tail. Third, in async code, funnel all outcomes — success, timeout, error — through a single guard (like the finish() helper above) so only the first one wins and the rest are no-ops.
When you do hit the error, read the stack trace rather than guessing. The frame that throws points at the second write, and the fix is almost always to add a return a few lines up or to collapse two response paths into one. If you are unsure how to read that trace back to the offending handler, our guide on reading a JavaScript stack trace walks through it frame by frame. And because so many of these bugs live in unawaited promises and racing callbacks, the patterns in fixing uncaught-in-promise errors pair naturally with this one.
None of this requires new libraries or clever abstractions. ERR_HTTP_HEADERS_SENT is Node telling you, precisely and early, that your control flow lets a request be answered more than once. Tighten the control flow — one path, one response, a return every time — and the error disappears along with a whole category of subtler bugs that were only waiting for the right timing to surface.
Install the free BugMojo extension to capture the full request flow — replay, console, and network — for the exact call that failed, so you find the double-response instead of guessing from one stack frame.
Install the free extensionFrequently asked questions
Frequently asked questions
Sources
- Node.js Errors — ERR_HTTP_HEADERS_SENT: an attempt was made to add more headers after the headers had already been sent — Node.js (2026)
- Node.js HTTP — response.headersSent and response.writeHead(): the response lifecycle and when headers become immutable — Node.js (2026)
- Writing middleware for use in Express apps — a middleware function either ends the request-response cycle or calls next(), not both — Express (2026)
- Error Handling in Express — delegating to the default handler with res.headersSent when a response has already started — Express (2026)
- HTTP Messages — MDN reference: the structure of a request/response and that status and headers are sent before the body — MDN Web Docs (2026)
- Express Error Handling Patterns — async try/catch, next(error), and Express 5's automatic async error propagation — Better Stack (2026)
- A comprehensive guide to error handling in Node.js — centralizing response and error logic — Honeybadger (2026)
Get bug-tracking insights, weekly.
Engineering deep-dives, QA playbooks, and honest tool comparisons. No spam — unsubscribe in one click.

