Node.js
Node.js — Cannot set headers after they are sent to the client
Written and reviewed by Sahil Srivastav
Error [ERR_HTTP_HEADERS_SENT]: Cannot set headers after they are sent to the clientWhat this error actually means
An HTTP response has a single header section. Once Node has committed that section to the outgoing response, a later attempt to change headers fails. The first write can commit implicit headers even when the code never called writeHead explicitly. A framework’s send, json or redirect usually makes that boundary less visible.
The stack trace points at the second attempt, but the first response is often where the ownership mistake began. A validation branch sends an error and continues; another branch later sends success. Alternatively, two asynchronous callbacks both believe they own completion. The fix is to make the terminal response path unique, not to suppress the exception at the later write.
headersSent and writableEnded describe different transitions. Headers may be sent while a streaming response is still open. Once streaming has begun, the application cannot replace the status with a JSON error response. It must follow the stream’s error contract, usually terminating an incomplete response and recording the failure.
Causes, most common first
- 1A terminal branch lacks a return. res.status(400).json(...) sends a response but does not return from the surrounding handler. Execution continues into database work and a success response. An explicit return documents that the branch ends request handling rather than merely performing an output operation.
- 2A timeout and normal completion race. A timer sends 504 while the downstream promise continues. When the promise resolves, the success path writes again. Guarding the write can avoid the exception but still leaves expensive abandoned work and possible side effects running after the caller has been told it failed.
- 3Middleware both responds and delegates. A middleware sends a response then calls next(), or invokes an error callback after another branch has completed. Mixing callback and promise styles can make ownership particularly unclear. One component should either finish the response or pass control onward.
- 4An error occurs after streaming started. The first body chunk already committed status and headers. A catch block written for buffered responses attempts to set Content-Type and send a structured error. The required recovery differs because part of the original representation is already on the wire.
When you see it
- The client receives the expected response while the server logs an exception afterwards
- Only validation failures, timeout races or late dependency failures trigger the error
- A streaming download starts successfully and then error middleware attempts JSON
- The same request ID appears in two separate response-completion log entries
How to diagnose it
Step 1
Identify both response attempts
Log request ID, handler branch, headersSent and writableEnded immediately before each terminal response in the suspected route. Add one finish listener for actual completion. A log of only the second exception misses the earlier branch that gave away ownership.
Step 2
Search all completion and delegation sites
Review the surrounding control flow rather than replacing every response call mechanically. Pay special attention to code after validation branches, timer callbacks and callback-style dependencies.
rg -n "res\.(send|json|end|redirect|write|writeHead)|next\(" srcStep 3
Delay the dependency past the timeout
In a controlled test, let the route’s timeout win, then resolve the original dependency. Assert that exactly one response is attempted and that cancellable downstream work is cancelled. Repeat with the dependency failing after the response completes.
Step 4
Locate the streaming commit boundary
Inspect whether pipe, write or a framework streaming helper sends the first chunk before the risky operation completes. If the endpoint needs an all-or-nothing status decision, complete the necessary validation before starting that stream.
The fix
Return immediately after sending a terminal validation or authorisation response. Prefer a linear async handler whose success response occurs once after all required awaited work. Keep response writing at the request boundary instead of allowing deeply nested service functions to write to res.
Give timeout and completion one shared cancellation and settlement mechanism. Abort supported downstream operations when the deadline expires. A final headersSent check can be defensive, but it should not be the only protection against two independent response owners.
In Express-style error middleware, delegate when headers have already been sent so the framework can handle the incomplete response according to its contract. Before headers are sent, select one error response and return. Do not call next after sending that error body.
For streaming endpoints, distinguish a failure before the first byte from a failure after commitment. Validate predictable errors up front; after commitment, record the failure and close the stream cleanly where possible. Clients must recognise truncation rather than accept a partial file as success.
async function handler(req, res, next) {
if (!req.body.name) {
return res.status(400).json({ error: "name is required" });
}
try {
const result = await createRecord(req.body);
return res.status(201).json(result);
} catch (error) {
return next(error);
}
}
function errorHandler(error, req, res, next) {
if (res.headersSent) return next(error);
return res.status(500).json({ error: "request failed" });
}How to stop it coming back
- Keep domain services independent of the HTTP response object
- Test late completion after timeout and errors after a stream’s first chunk
- Require terminal response branches to end control flow explicitly
- Track response completion and downstream cancellation under the same request ID
FAQ
Can I wrap every send in if (!res.headersSent)?
That may hide duplicate writes while leaving the second branch running and changing state. Find why both branches own the response, then cancel or join their work. Use the check where the API genuinely needs to distinguish pre-commit and post-commit error handling.
Does res.json stop the function?
No. It sends the response through the framework but JavaScript continues executing subsequent statements. Returning the call ends the current function; it does not cancel asynchronous work already started elsewhere.
Why can I not send a 500 after a download fails?
Once the original headers are transmitted, the status is already part of the response. Appending JSON creates a corrupt mixed representation. Terminate the incomplete stream and let the client treat it as a failed transfer.
Related
Other errors engineers hit next to this one
- Event loop blocked by synchronous work
- pg client already connected or released twice
- Sequelize / Knex pool acquire timeout
- ERR_STREAM_PREMATURE_CLOSE during an upload
- Process exits before asynchronous writes finish
- ERR_MODULE_NOT_FOUND during ESM/CommonJS migration
- Unbounded JSON body blocks the loop or exhausts memory
- Undici / fetch connections remain occupied