Skip to content
Preptima
mediumCodingConceptMidSenior

Your logs are full of Cannot set headers after they are sent. Walk me through how a request gets into that state.

Something responded twice. The first response flushed the head, so any later attempt to set a status or header throws ERR_HTTP_HEADERS_SENT, and because the error handler cannot respond either, Express destroys the socket and the client sees a truncated body.

4 min readUpdated 2026-07-29Target archetype: Big Tech, Enterprise Captive, Product Startup
Practice answering out loud

What the interviewer is scoring

  • Does the candidate enumerate the double-response paths rather than describing the message as a mystery
  • Whether they separate setting a header from flushing the head, and can say what res.headersSent reports
  • That the error handler's failed second response is identified as a consequence rather than as the cause
  • Whether the candidate can state what the client observes when Express destroys a socket mid-body
  • Does the answer reach for a structural fix instead of guarding every write with a headersSent check

Answer

The message is about a second response, not a header

ERR_HTTP_HEADERS_SENT never means you formatted a header wrongly. It means the status line and headers for this response have already gone out on the wire, and something in your code has just tried to set another one. HTTP gives you exactly one head per response and no way to retract it, so the runtime refuses rather than corrupting the stream. Read the message as "this request was answered twice" and the search space collapses to the handful of places where that can happen.

Set is not the same as flushed

res.setHeader only mutates an in-memory map, and you may call it as many times as you like. The head is written when something first needs to send bytes: res.writeHead, the first res.write, or res.end. Express's res.send and res.json do the whole sequence at once — set Content-Type and Content-Length, then call end — which is why a duplicate res.json is the usual culprit even though the visible failure is a header assignment.

res.headersSent is the flag that separates the two states, and its most useful property is that it is the only reliable way for shared code to know whether it still has a response to give. An error handler that has to decide between rendering an error body and giving up needs exactly that bit.

The missing return

app.get('/users/:id', async (req, res) => {
  const user = await repo.find(req.params.id);
  if (!user) {
    res.status(404).json({ error: 'not_found' }); // sends and ends the response
  }
  // no return above, so execution continues with user === undefined
  res.json({ id: user.id }); // <-- throws ERR_HTTP_HEADERS_SENT
});

Trace a request for a user that does not exist. The 404 body goes out complete and correct; the client is already satisfied. Execution falls through, res.json calls res.set('Content-Type', ...), Node sees headersSent === true and throws. Under Express 4 that throw happens inside an async function whose promise Express does not observe, so it becomes an unhandled rejection rather than a 500, and on a default Node configuration the process exits. One missing return on a not-found path is enough to make a 404 lethal.

The same shape appears as next() after responding, which lets the next matching route run and respond again, and as a try/catch whose catch sends an error body for an exception thrown after the success body was already sent.

Why the error handler cannot save you

Express error middleware receives the error and does what it always does: res.status(500).json({ error: 'internal' }). That call throws too, for exactly the same reason. The error is now escaping the error handler, so Express hands it to its final handler, which checks res.headersSent, finds that it cannot change the status, and destroys the socket.

That is the detail worth carrying into an interview, because it explains the symptom your users actually report. They do not see a 500. They see a 200 with a body that stops halfway, or a connection reset, and it is not reproducible from the log line because the log line is about the second response while the visible damage is to the first. If the first response was a stream — a file download, a server-sent-event feed, a large JSON array written in chunks — the client gets a valid status and a truncated payload, which any client that trusts a 2xx will happily parse into garbage.

Races with no missing return anywhere

Not every instance is a control-flow slip. A timeout middleware that replies with 503 after two seconds does nothing to stop the handler it was protecting, so when the slow query finally returns at three seconds the handler responds into a request that has already been answered. The same goes for a client that aborts: the socket is gone, and a handler that keeps working and then writes will find the response in a state it did not expect.

Both of these are cancellation problems wearing a header error as a disguise. The timeout has to be plumbed into the work — an AbortSignal passed to the fetch and the database driver — so that the losing path stops rather than merely being ignored. Adding if (res.headersSent) return at each write point suppresses the exception without stopping the wasted work, and it hides the race that will surface next as a duplicate side effect rather than a duplicate response.

Making it structurally hard

The durable fixes are boring and they compose. Return the value from a handler and let one thin layer perform the single res.json, so there is only ever one place that can send. Treat res.send as terminal by convention and always write return res.status(404).json(...). Turn on the lint rules for unreturned promises and unreachable code. And give every abandoned path something that actually cancels it, so a late winner has no work left to finish. Nest pushes you towards this by construction, since a controller method returns a value and the framework owns the response, which removes the whole category rather than guarding against it.

Cannot set headers after they are sent is a report that the request was answered twice, and the interesting damage is not the exception but the first response, which the failing error handler then truncates by destroying the socket.

Likely follow-ups

  • A timeout middleware replies at 2 seconds and the real handler resolves at 3. How do you make the late reply impossible rather than merely logged?
  • Where does a response-time header have to be set on a streamed response, and how do you record the duration if you cannot set one?
  • What does res.end do that res.send does not, and when does the difference matter?
  • How would you detect double responses in production before a user reports a truncated payload?

Related questions

Further reading

expresshttperror-handlingmiddlewarenodejs