Node.js 'Headers Already Sent' Error Fix
Node.js's 'Cannot set headers after they are sent' error explained: why it happens, the real fix, and how to stop it recurring in production.

Error: Cannot set headers after they are sent to the client. That's the entire message Node.js gives you. No line number pointing at the actual bug, no stack trace that reads clearly on the first pass — just a crash in your terminal while the request itself may have already gone out with a 200.
This is one of the most common runtime errors in Express and plain Node.js HTTP servers, and it almost never means what people assume it means. It's not a headers problem. It's a control-flow problem — your handler tried to respond to the same request twice, and Node only lets you do that once.
What "Headers Already Sent" Actually Means
HTTP headers finalize the moment the first byte of the response body goes out. Calling res.send(), res.json(), res.end(), or res.redirect() locks the response. Any second call — even from a completely different code path — throws this exact error, because Node can't append new status codes or headers to a response that's already left the process.
The error message names the symptom, not the cause. The actual cause is almost always a code path that runs after a response has already fired, usually because a return was skipped or two independent pieces of logic both think they're responsible for responding.
The #1 Cause: A Missing return After res.send()
This is the pattern I see most often in client codebases, including my own early Express code years ago:
app.get('/users/:id', async (req, res) => {const user = await User.findById(req.params.id);if (!user) {res.status(404).json({ error: 'User not found' });// no return here — execution keeps going}res.status(200).json(user);});
When the user doesn't exist, Node sends the 404, then falls straight through to the next line and tries to send a 200 on the same response. The fix is one keyword: return res.status(404).json(...). I'd argue this single missing return accounts for more of these errors than every other cause combined, which is why it's worth checking first before assuming something more exotic is going on.
Async Code Hides the Same Bug Better
Once async/await and Promises enter a route handler, the same missing-return bug gets harder to spot because the two "responses" aren't sitting next to each other anymore:
app.post('/orders', async (req, res) => {try {const order = await createOrder(req.body);res.status(201).json(order);} catch (err) {logger.error(err);}res.status(200).json({ status: 'queued' }); // always runs});
The success path responds inside the try, but there's no return, so control falls through to the final line regardless of what happened above. On the happy path this silently double-responds; on the error path it does too, just with different status codes racing each other. This is the exact shape of bug that passes code review, because both response calls look individually correct.
The Fix: Treat Every Response as an Exit Point
The pattern I actually use now, and the one I'd push back on if a teammate skipped it: every res.send()/res.json()/res.end() gets a return in front of it, no exceptions, even when it's the last statement in the function. It costs nothing and removes an entire category of bug permanently.
app.post('/orders', async (req, res) => {try {const order = await createOrder(req.body);return res.status(201).json(order);} catch (err) {logger.error(err);return res.status(500).json({ error: 'Could not create order' });}});
For cases where you genuinely can't restructure the flow — a webhook handler with multiple async branches, for instance — guard with res.headersSent before responding: if (!res.headersSent) res.status(500).json(...). I only reach for this as a safety net, not as the primary fix, because it treats the symptom rather than the actual control-flow mistake.
Where This Actually Bites in Production
The place this shows up hardest is webhook and payment-gateway handlers, where a request can legitimately hit multiple async branches — a database write, a queue publish, a third-party API call — and more than one of them tries to acknowledge the request on timeout or retry. Stripe and similar providers will retry a webhook that doesn't get a clean 200 fast enough, which means a slow double-response bug doesn't just throw once — it throws on every retry, and can end up processing the same event multiple times if the handler also isn't idempotent.
Catching This Before It Ships
Two things actually prevent this from recurring on a team, and neither is "be more careful":
- Turn on ESLint's
consistent-returnrule — it flags functions that sometimes return a value and sometimes don't, which catches most missing-return bugs at lint time instead of in production logs. - Add a request-scoped flag or middleware that logs a warning the moment a handler attempts a second write to the same response, before Node throws — this turns a crash into a visible log line during development.
Frequently Asked Questions
Does this error mean my headers object is corrupted? No — despite the wording, nothing is wrong with the headers themselves. The message fires purely because a second response attempt was made on a connection that already finalized one.
Why does this only show up under load or in production, not locally? Usually because the second code path is timing-dependent — a slow database call, a retry, or a timeout race that only wins often enough to matter once real traffic and network latency are involved. Local testing with fast mocked calls rarely exposes the race.
Conclusion
If you hit "Cannot set headers after they are sent," don't go looking for a header configuration bug — go find the second place in that request's code path that's trying to respond. Put a return in front of every res.send(), res.json(), and res.end() in the codebase as a standing rule, and treat res.headersSent checks as a last-resort guard for genuinely branchy async flows, not a substitute for fixing the control flow itself.


