ERR_HTTP_HEADERS_SENT in Next.js: the missing return, and four other causes
- Next.js
- Node.js
- API Routes
- Debugging
- Production
This one arrives in your logs with a stack trace that points at Node's internals rather than your code:
Error [ERR_HTTP_HEADERS_SENT]: Cannot set headers after they are sent to the client
at ServerResponse.setHeader (node:_http_outgoing:659:11)
at ServerResponse.header (/app/node_modules/next/dist/compiled/...)
at ServerResponse.status (...)
at ServerResponse.json (...)
Every frame in that trace belongs to Node or Next. None of them tell you which of your handlers did it. That is the actual difficulty with this error — the message is precise about what happened and silent about where.
What happened is simple: something wrote the response twice. HTTP headers go out with the first byte of the body, so the second write has nothing left to configure. Node throws rather than corrupt the response.
Why it tends to be a production-only crash is less obvious, and it is the reason this sits in the same family as memory leaks and hydration mismatches: the second write usually only happens on an error path, and error paths do not run on your laptop.
Finding the handler, when the stack trace will not tell you
The stack shows where the second write happened, not the first. You need the first. Patch the response object early and record it:
// pages/api/_debug-double-write.ts — a temporary diagnostic, not a fix
export function traceResponse(res) {
const originalEnd = res.end
let firstWrite = null
res.end = function (...args) {
if (firstWrite) {
console.error('DOUBLE WRITE. First write was at:\n', firstWrite)
console.error('Second write is at:\n', new Error().stack)
} else {
firstWrite = new Error().stack
}
return originalEnd.apply(this, args)
}
}
Call it at the top of the suspect handler. The first stack it prints is the line you actually need to fix; the second is the one Node was already going to show you.
For a faster narrowing pass in a codebase you do not know, the shape of the bug is grep-able. A res.json, res.send, res.redirect or res.end that is not preceded by return on the same line is the overwhelming majority of cases:
grep -rn --include=*.ts --include=*.js -E '^\s+res\.(json|send|redirect|end|status)' pages/api app/api \
| grep -v 'return'
That is deliberately noisy — a final write at the end of a handler is perfectly fine without return. It is a list of places to read, not a list of bugs.
Cause 1: the missing return after an early exit
This is the one. If you only check for one thing, check for this.
// Broken: the guard writes 401, then execution continues to the next line
// and writes 200 as well.
export default async function handler(req, res) {
const user = await getUser(req)
if (!user) {
res.status(401).json({ error: 'unauthorized' })
}
const data = await fetchData(user.id) // also: user is null here
res.json(data)
}
res.status(401).json(...) does not stop the function. It sends the response and returns normally, and the handler keeps going — into fetchData(user.id) with a null user, which throws, which triggers whatever error handling writes the response a second time.
Note the two bugs stacked on each other. The ERR_HTTP_HEADERS_SENT in your logs is the second one. The null dereference underneath it is the one you would rather have known about.
// Fixed: return the write.
export default async function handler(req, res) {
const user = await getUser(req)
if (!user) {
return res.status(401).json({ error: 'unauthorized' })
}
const data = await fetchData(user.id)
return res.json(data)
}
return res.json(...) rather than res.json(...); return is worth adopting as a habit — it makes the guard a single expression, so the failure mode cannot be reintroduced by someone adding a line underneath it.
Cause 2: writing from inside a callback and after it
// Broken: on error, the callback writes 500 — and the outer code has
// already written 200, because it did not wait.
export default function handler(req, res) {
stream.on('error', (err) => {
res.status(500).json({ error: err.message })
})
stream.pipe(res)
res.status(200)
}
Anything asynchronous that can write the response needs a single owner. The reliable pattern is one awaited path with try/catch, rather than callbacks that each believe they are in charge:
export default async function handler(req, res) {
try {
await pipeline(stream, res) // node:stream/promises
} catch (err) {
// The guard: if the stream already sent bytes, the response is
// committed and the only honest thing left is to close it.
if (res.headersSent) return res.destroy()
return res.status(500).json({ error: 'stream failed' })
}
}
res.headersSent is the check that makes error handlers safe. Any catch block that writes a response should consult it first — by the time you are in a catch, you genuinely do not know whether the happy path already replied.
Cause 3: a finally block that also responds
// Broken: finally runs after both the try and the catch, so the
// successful path writes twice.
export default async function handler(req, res) {
try {
const data = await load()
res.json(data)
} catch (err) {
res.status(500).json({ error: 'failed' })
} finally {
await db.release()
res.end() // always runs — always the second write
}
}
finally is for releasing resources, never for responding. Keep the cleanup and drop the write:
export default async function handler(req, res) {
try {
const data = await load()
return res.json(data)
} catch (err) {
return res.status(500).json({ error: 'failed' })
} finally {
await db.release() // cleanup only
}
}
Cause 4: middleware and handler both replying
// Broken: withAuth writes 403 and then calls the handler anyway.
const withAuth = (handler) => async (req, res) => {
if (!req.headers.authorization) {
res.status(403).json({ error: 'forbidden' })
}
return handler(req, res)
}
Same missing return as Cause 1, one layer up, and harder to see because the two writes are in different files. A wrapper that rejects a request must not call through:
const withAuth = (handler) => async (req, res) => {
if (!req.headers.authorization) {
return res.status(403).json({ error: 'forbidden' })
}
return handler(req, res)
}
If you are chaining several wrappers, the invariant to hold is: every wrapper either responds or delegates, never both.
Cause 5: a timer or unawaited promise that outlives the response
// Broken: the response is sent immediately; the timeout fires afterwards
// and writes into a response that is already closed.
export default async function handler(req, res) {
const timer = setTimeout(() => {
res.status(504).json({ error: 'timeout' })
}, 5000)
const data = await slowQuery()
res.json(data) // timer is never cleared
}
When slowQuery() returns in under five seconds this looks fine in every test you write. In production, under load, some requests take longer — and those get two responses.
export default async function handler(req, res) {
const timer = setTimeout(() => {
if (!res.headersSent) res.status(504).json({ error: 'timeout' })
}, 5000)
try {
const data = await slowQuery()
if (!res.headersSent) return res.json(data)
} finally {
clearTimeout(timer) // always clear it
}
}
Note that this cause overlaps directly with an SSR memory leak: the same uncleaked timer that double-writes also retains req and res for its full duration. If you are seeing this error and memory climbing on the same service, one uncleared timer can be behind both.
A quick checklist
- Grep every
res.json/res.send/res.redirect/res.endthat is not on areturnline. - Check every guard clause — auth, validation, method checks — for a missing
return. - Check every
catchthat responds for ares.headersSentguard. - Check every
finallyfor a write that should not be there. - Check middleware and HOF wrappers: respond or delegate, never both.
- Check every
setTimeoutin a handler for a matchingclearTimeout. - If it is still not obvious, patch
res.endto print the stack of the first write.
When this is worth outside help
A single missing return is a five-minute fix once you have found it. It is worth bringing someone in when the error appears in aggregate in your logs without a reproducible request, when it arrives from inside a wrapper chain or a dependency's middleware rather than your own handlers, or when it is one symptom among several — because a service that double-writes responses under load is usually a service where the error paths have never been exercised at all.
That combination is what an audit is actually for. Intermittent failures that only appear under real traffic tend to arrive in groups: I wrote up what stabilizing a high-traffic SSR marketplace involved, where the intermittent request failures and the memory problem underneath them turned out to be the same story.
A Production Health Audit covers exactly this ground: I read the code, exercise the error paths that your tests do not, and hand back a written list of what breaks under load, what it costs, and the fix for each one.