Node.js · beginner

Node.js ERR_HTTP_HEADERS_SENT: find the second response write

ERR_HTTP_HEADERS_SENT fires in Node.js when application code attempts to write, set, or end the response after the first byte of headers has already been flushed. This guide focuses on locating the second response write inside async code paths, verifying the offending boundary, and applying conditional fixes that do not mask the underlying double-response bug.

The symptoms

  • Node.js logs `Error: ERR_HTTP_HEADERS_SENT` (or `Cannot set headers after they are sent to the client`) at the moment a callback resolves after the response has already started.
  • An Express or http handler appears to send data twice: once normally, then again from a deferred callback, timer, or promise resolution.
  • Clients observe a truncated or duplicated body, or receive the response and then see a `write after end` socket-level event that terminates the connection.
  • The error stack points into user code rather than into Node.js internals, typically inside a `.then`, `setTimeout`, stream callback, or middleware `next()` path.
  • Production logs repeat the same stack trace under load, while single-request manual testing does not reproduce it because timing differs.

Likely causes

  • An async callback (database query, fetch, queue consumer, retry) resolves after `res.end()` has already been called and then calls `res.send`, `res.json`, `res.write`, or `res.end` again.
  • `next()` is invoked twice in an Express middleware chain, causing the handler to run twice and the response to be sent twice.
  • A `setTimeout` or `setImmediate` scheduled inside the handler fires after the response is already finished and tries to mutate headers.
  • Stream piping (`pipe`) is combined with an explicit `res.end()`, producing two terminations on the same response object.
  • An error-handling middleware calls `next(err)` after headers have already been flushed by a streaming response.
  • A shared response helper closes the response, but the calling handler continues to a second `await` branch that also writes to it.

First ten minutes

  1. 01Capture the exact error message, stack trace, request method, path, and request id from the Node.js process log; confirm the error class is `ERR_HTTP_HEADERS_SENT` rather than a generic `TypeError`.
  2. 02Identify which request handler (route file, middleware name, controller function) appears at the top frames of the stack; circle every frame that touches `res`.
  3. 03Open that handler and list every code path that can call `res.send`, `res.json`, `res.write`, `res.end`, `res.setHeader`, or `next()`.
  4. 04Trace each of those calls to the await, callback, timer, or stream event that fires it; mark any that can run after the response body has started streaming.
  5. 05Grep the handler and its helpers for shared mutable state (a module-level object, a singleton service) that might hold a reference to `res` across requests and write twice.
  6. 06Decide which of the listed causes matches: deferred async write, double `next()`, late timer, double pipe/terminate, or error-after-stream. Do not change code until a cause is matched to a frame.

Evidence to collect

  • Full Node.js stack trace including file path and line number for the `ERR_HTTP_HEADERS_SENT` throw site.
  • HTTP access log entry for the same request id, showing status code, response size, and content-length header value.
  • Source listing of every `res.*` call and every `next()` invocation in the suspect handler, with the surrounding control flow annotated.
  • Timing evidence: timestamps of the first response byte and of the deferred callback that triggered the second write, from logs or instrumentation.
  • Reproducer request: method, path, headers, and payload that reliably causes the second write under the same conditions as production.

Where to look

  • Boundary: the Node.js HTTP server response object (`http.ServerResponse`) inside an application handler — the point where user code crosses from application logic into the Node.js HTTP layer.
  • Boundary: the Express response wrapper, because `res.send`, `res.json`, and `res.end` all eventually delegate to the underlying `ServerResponse` and share the same "headers sent" state.
  • Boundary: the async control-flow join where a promise, callback, or timer resumes after `res.end()` has already executed.
  • Boundary: any middleware that conditionally calls `next()` or writes to `res` from both a success path and an error path.
  • Boundary: stream pipelines where the readable stream is piped into `res` and an explicit termination is also scheduled.

Diagnostic steps

  1. 01Read the stack trace top-down and identify the first frame in user code that calls a response method; this is the "second response write" site, not the first.
  2. 02Search the handler for the matching earlier write: locate the first `res.send`, `res.json`, `res.write`, `res.end`, or `pipe(res)` that precedes the failing call in execution order.
  3. 03Determine whether the two writes are reachable from the same request: if yes, the cause is a double-write inside one handler; if no, the cause is shared response state across requests.
  4. 04For deferred async causes, inspect every `await`, `.then()`, `setTimeout`, `setImmediate`, `process.nextTick`, and event listener registered inside the handler; classify each as "before headers sent" or "possibly after headers sent".
  5. 05For double `next()`, count `next` invocations reachable in the middleware chain and verify that error middleware does not run after the response has been streamed.
  6. 06For double-termination, compare the explicit `res.end()` call site with the readable stream's `end` event and the `pipe()` destination to see which one fires first.
  7. 07Add a guarded write (a flag that records whether `res.headersSent` was already true at entry to the failing call) to confirm the hypothesis without changing behavior in production.
  8. 08Reproduce the failure locally with the captured request, then remove the guard before committing a structural fix.

Common mistakes

  • Suppressing the error with a try/catch around the second write; this hides the bug and can leave the connection in an undefined state instead of fixing the double response.
  • Removing the second write entirely without confirming whether the first write actually produced a complete response, which can silently drop data the client expects.
  • Refactoring to `return res.send(...)` in early-exit branches while leaving a downstream branch that still writes, because `return` does not stop already-scheduled callbacks.
  • Assuming the error is in the framework; the Node.js documentation attributes this to application code, not to http internals.
  • Adding `res.headersSent` checks as a permanent fix instead of a diagnostic; the check should prove the cause, then be replaced by removing the second write.

Safe fixes

  • If a deferred callback writes after `res.end()`, remove the second write by returning early from the callback once `res.writableEnded` is true, and stop scheduling that work in the first place (clear the timer or unsubscribe the listener).
  • If `next()` is called twice, ensure each conditional branch has exactly one terminal `next()` and that error forwarding uses a single guard such as `if (err) return next(err)` placed before any response write.
  • If a `setTimeout`/`setImmediate` writes to `res`, move the work before the response is ended, or capture the needed data into a local variable and end the response after the timer, but never both.
  • If `pipe(res)` and an explicit `res.end()` both terminate the response, drop the explicit `res.end()` and let the source stream's `end` event close the response.
  • If shared state holds a reference to `res`, stop caching the response object at module scope; pass it through function arguments or close over it per-request only.
  • Each fix above is conditional on the matching diagnostic evidence: identify the cause, then apply the corresponding structural change; do not apply a generic "guard every write" patch.

Prove the fix

  1. 01Run the captured reproducer request against the fixed build and confirm the Node.js process log no longer contains `ERR_HTTP_HEADERS_SENT` for that request id.
  2. 02Inspect the HTTP access log for the same request id and verify a single status code, a single content-length matching the body, and no follow-up write after the response is closed.
  3. 03Replay the request under the original production load pattern (concurrency, payload size) and observe zero `ERR_HTTP_HEADERS_SENT` occurrences over a fixed observation window.
  4. 04Add a unit or integration test that asserts the handler calls `res.send`/`res.end` exactly once for the scenario that previously double-wrote; the test should fail if either write is removed or duplicated.
  5. 05Confirm the client receives a complete, well-formed body with no truncated JSON, no duplicated payload, and no premature connection close.

Prevention and next steps

  • Establish a handler convention that each request has exactly one terminal response write, enforced by code review and a lint rule that flags more than one `res.send`/`res.json`/`res.end` per handler.
  • Centralize response termination in a small helper that sets a per-request "done" flag and short-circuits any second attempt, so the helper itself is the single place that decides whether a write is allowed.
  • Avoid storing `req` or `res` references in module-level caches, singletons, or background queues; scope them to the request lifetime.
  • When using async/await, ensure every error path either forwards via a single `next(err)` placed before any `res.*` call or returns a value; do not mix the two.
  • Add a periodic log scan that alerts on any occurrence of `ERR_HTTP_HEADERS_SENT` so a regression surfaces immediately rather than at the next incident.

Safe commands and checks

node --stack-trace-limit=50 app.js  # raise stack depth so the second-write frame is visible in the log
grep -nE "res\.(send|json|end|write|writeHead|setHeader)" path/to/handler.js  # enumerate every response write inside the suspect handler
grep -nE "next\(" path/to/middleware.js  # count and locate every next() invocation in a middleware file
grep -nE "pipe\(res\)|res\.end\(\)" path/to/stream-handler.js  # detect double-termination between a pipe and an explicit end
node -e "console.log(process.versions.node)"  # record the Node.js version that produced the stack trace for later correlation with the official errors documentation