ERR_HTTP_HEADERS_SENT when your code calls res.send(), res.json(), res.redirect(), res.setHeader(), or res.end() a second time on the same request. The most common fix is adding return before every response call (return res.json({...})) so execution stops immediately. Also check for double next() calls in middleware and async callbacks that fire after the response was already sent.
What is ERR_HTTP_HEADERS_SENT?
ERR_HTTP_HEADERS_SENT is thrown by Node.js's HTTP internals when a response has
already been written to the TCP socket and code subsequently tries to modify headers or write
more data. HTTP responses are strictly one-way and one-time: once the status line and headers
are flushed, the response is sealed. Any call to res.setHeader(),
res.writeHead(), res.write(), or res.end() on an
already-finished response triggers this error.
In Express, res.send(), res.json(), res.render(), and
res.redirect() all call res.end() internally, sealing the response.
In Fastify, reply.send() does the same. In raw http.createServer,
res.end() or res.write() after a prior res.end() will
trigger it.
See the official Node.js error documentation for the internal definition.
Full Error Example
Error [ERR_HTTP_HEADERS_SENT]: Cannot set headers after they are sent to the client
at new NodeError (node:internal/errors:405:5)
at ServerResponse.setHeader (node:_http_outgoing:644:11)
at ServerResponse.header (/app/node_modules/express/lib/response.js:794:10)
at ServerResponse.json (/app/node_modules/express/lib/response.js:278:10)
at /app/src/routes/users.js:24:9 <-- second res.json() call
at Layer.handle [as handle_request] (/app/node_modules/express/lib/router/layer.js:95:5)
at next (/app/node_modules/express/lib/router/route.js:137:13)
at Route.dispatch (/app/node_modules/express/lib/router/route.js:112:3)
at /app/src/routes/users.js:18:5 <-- first res.json() call succeeded here
code: 'ERR_HTTP_HEADERS_SENT'
The stack trace always shows two lines in your own code: the line where the second response call was made (the one that threw) and the line where the first succeeded. Start debugging at your own file lines, not the Express or Node.js internals.
Error Object Properties
| Property | Value | Notes |
|---|---|---|
error.code | 'ERR_HTTP_HEADERS_SENT' | Always this exact string |
error.message | 'Cannot set headers after they are sent to the client' | Consistent across Node.js versions |
error.name | 'Error' | Base Error class, not TypeError |
res.headersSent | true | Check this before any response call in defensive code |
All Causes at a Glance
| Cause | Symptom | Fix |
|---|---|---|
Missing return before response call |
Two res.json() / res.send() calls in one handler |
return res.json({...}) |
Missing return after next() |
Middleware continues executing after calling next() |
return next() |
next(err) after already responding |
catch block calls both res.send() and next(err) |
Use only next(err) in catch; never send and forward |
| Async callback fires after response | setTimeout, Promise.then, DB callback, event fires late |
Guard with if (res.headersSent) return; |
Double next() call in middleware |
next() called twice in same middleware function |
return next() on every call path |
res.setHeader() after res.end() |
Raw http module header set too late |
Set all headers before writing any body data |
Fastify reply.send() called twice |
Async handler returns a value AND calls reply.send() |
Return value only; do not also call reply.send() |
Express error middleware sends then calls next() |
Error handler sends a response and also calls next(err) |
Error middleware must send a response OR call next(), never both |
Cause 1 – Missing return Before res.send() (Most Common)
res.send(), res.json(), and res.redirect() do
not stop the execution of your route handler function. They are regular
JavaScript function calls. Without a return statement, code after the response
call continues running and hits another response call.
// BROKEN – missing return; execution falls through to the second res.json()
app.post('/users', async (req, res) => {
if (!req.body.email) {
res.status(400).json({ error: 'Email is required' }); // sends response
// No return here — code continues!
}
// This line runs even when the 400 was already sent:
const user = await createUser(req.body);
res.status(201).json(user); // Error [ERR_HTTP_HEADERS_SENT]
});
// FIXED – return before every response call
app.post('/users', async (req, res) => {
if (!req.body.email) {
return res.status(400).json({ error: 'Email is required' }); // stops here
}
const user = await createUser(req.body);
return res.status(201).json(user);
});
res.* call with return.
It costs nothing and prevents the entire class of "double response" bugs. ESLint's
consistent-return rule and TypeScript's return-type inference can enforce this
automatically.
Cause 2 – Double next() Calls in Middleware
Express middleware must call next() exactly once, or send a response —
never both, and never next() twice. Without a return after
next(), the middleware continues executing and may call next()
again, passing the same request to two subsequent route handlers.
// BROKEN – double next() call
function authMiddleware(req, res, next) {
if (!req.headers.authorization) {
next(new Error('Unauthorized')); // passes to error handler
// No return — code continues!
}
// Called again for authenticated requests AND for unauthorized ones:
next(); // Error [ERR_HTTP_HEADERS_SENT] when the error handler also responds
}
// FIXED – return after every next() call
function authMiddleware(req, res, next) {
if (!req.headers.authorization) {
return next(new Error('Unauthorized')); // stops here
}
return next(); // only reached for authorized requests
}
// ALSO BROKEN – calling next() and then responding
function loggingMiddleware(req, res, next) {
next(); // passes control to next handler which sends a response
res.setHeader('X-Request-Id', generateId()); // response already sent!
// FIXED – set headers BEFORE calling next()
// res.setHeader('X-Request-Id', generateId());
// next();
}
Cause 3 – Calling next(err) After Already Responding
A common pattern is to wrap an async handler in a try/catch. The catch block should
either send an error response or call next(err) to delegate to
Express's error middleware — never both. Doing both sends one response in the catch block
and then the error middleware tries to send another.
// BROKEN – sends a response AND forwards to error middleware
app.get('/data', async (req, res, next) => {
try {
const result = await fetchData();
res.json(result);
} catch (err) {
res.status(500).json({ error: err.message }); // sends response
next(err); // error middleware tries to send ANOTHER response
}
});
// FIXED – use only next(err) in catch; let error middleware handle the response
app.get('/data', async (req, res, next) => {
try {
const result = await fetchData();
res.json(result);
} catch (err) {
next(err); // delegate completely to error middleware
}
});
// Error middleware (registered last):
app.use((err, req, res, next) => {
if (res.headersSent) return next(err); // safety guard
res.status(err.status || 500).json({ error: err.message });
});
Cause 4 – Async Callback Fires After Response is Finished
Any code that runs asynchronously — a setTimeout, a database callback, a
Promise.then(), or an event emitter listener — may execute after the response
was already sent by a different code path (a timeout, an error handler, or an earlier
branch). The fix is to guard every async callback with if (res.headersSent) return;.
// BROKEN – setTimeout fires after the response was already sent
app.get('/slow', (req, res) => {
// Request-level timeout sends a response after 5 seconds
setTimeout(() => {
res.status(503).json({ error: 'Timeout' });
}, 5000);
// If fetchData() resolves in 3 seconds, this sends first:
fetchData().then(data => {
res.json(data); // sends after 3s
// 2 seconds later, the setTimeout fires and tries to send again — ERROR
});
});
// FIXED – guard the async callback with res.headersSent
app.get('/slow', (req, res) => {
const timer = setTimeout(() => {
if (res.headersSent) return; // data already came back — do nothing
res.status(503).json({ error: 'Request timed out' });
}, 5000);
fetchData()
.then(data => {
clearTimeout(timer); // cancel the timeout since we have a result
if (res.headersSent) return;
res.json(data);
})
.catch(err => {
clearTimeout(timer);
if (res.headersSent) return;
res.status(500).json({ error: err.message });
});
});
// BEST – use async/await with AbortController for clean timeout handling
app.get('/slow', async (req, res) => {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 5000);
try {
const data = await fetchData({ signal: controller.signal });
clearTimeout(timer);
res.json(data);
} catch (err) {
clearTimeout(timer);
if (err.name === 'AbortError') {
return res.status(503).json({ error: 'Request timed out' });
}
res.status(500).json({ error: err.message });
}
});
Cause 5 – Async Middleware Calling next() After Response
When async middleware awaits something and then calls next(), the
subsequent route handler may already have sent a response (e.g. if another middleware
errored). Always check res.headersSent before calling next() in
async middleware.
// BROKEN – async middleware calls next() after awaiting; response may already be sent
async function rateLimitMiddleware(req, res, next) {
const allowed = await checkRateLimit(req.ip); // slow DB check
if (!allowed) {
return res.status(429).json({ error: 'Too Many Requests' });
}
next(); // by the time this runs, another middleware may have sent a 500
}
// FIXED – guard next() with headersSent check
async function rateLimitMiddleware(req, res, next) {
try {
const allowed = await checkRateLimit(req.ip);
if (res.headersSent) return; // another middleware already responded
if (!allowed) {
return res.status(429).json({ error: 'Too Many Requests' });
}
next();
} catch (err) {
if (!res.headersSent) next(err);
}
}
Cause 6 – Fastify: reply.send() Called Twice
Fastify's async handlers automatically send the return value as the response body. If you
also call reply.send() manually inside an async handler that returns a value,
Fastify throws the equivalent of ERR_HTTP_HEADERS_SENT. Fastify exposes
reply.sent as the guard property.
// BROKEN in Fastify – returns a value AND calls reply.send()
fastify.get('/user/:id', async (request, reply) => {
const user = await getUser(request.params.id);
reply.send(user); // sends the response
return user; // Fastify tries to send again — Error: Reply already sent
});
// FIXED – choose one: return the value OR call reply.send(), not both
fastify.get('/user/:id', async (request, reply) => {
const user = await getUser(request.params.id);
return user; // Fastify sends this automatically
});
// Alternative – use reply.send() and return nothing (or return reply)
fastify.get('/user/:id', async (request, reply) => {
const user = await getUser(request.params.id);
reply.send(user);
// return nothing — Fastify sees reply.sent = true and skips auto-send
});
// Guard pattern in Fastify for defensive async code:
fastify.get('/stream', async (request, reply) => {
try {
const data = await slowOperation();
if (reply.sent) return; // already responded (e.g. request was aborted)
return data;
} catch (err) {
if (!reply.sent) reply.status(500).send({ error: err.message });
}
});
Cause 7 – Raw http.createServer: Headers Set After res.end()
In raw Node.js HTTP handlers (no Express), headers must be set before any call to
res.write() or res.end(). Calling res.setHeader()
or res.writeHead() after the response body has been written triggers the error.
const http = require('http');
// BROKEN – setHeader after end
const server = http.createServer((req, res) => {
res.end('Hello World'); // flushes headers + body
res.setHeader('X-Custom', 'value'); // Error [ERR_HTTP_HEADERS_SENT]
});
// FIXED – set all headers before write/end
const server2 = http.createServer((req, res) => {
res.setHeader('Content-Type', 'text/plain');
res.setHeader('X-Custom', 'value');
res.writeHead(200); // optional; writeHead must come before write/end too
res.end('Hello World');
});
// BROKEN – writeHead after implicit header flush via res.write()
const server3 = http.createServer((req, res) => {
res.write('chunk one'); // this implicitly sends headers with status 200
res.writeHead(404); // Error [ERR_HTTP_HEADERS_SENT] — too late
res.end();
});
// FIXED – write status in writeHead or statusCode before any write()
const server4 = http.createServer((req, res) => {
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.write('chunk one');
res.write('chunk two');
res.end();
});
The res.headersSent Guard Pattern
res.headersSent is a built-in boolean property on Node.js's
ServerResponse object (and on Express's Response wrapper). It is
false before any response data is sent and true immediately after.
Use it as the primary defensive guard anywhere code might try to respond twice.
// Universal guard — works in Express, raw http, and any framework built on Node.js http
function safeRespond(res, statusCode, body) {
if (res.headersSent) {
console.warn('safeRespond: response already sent, skipping', body);
return;
}
res.status(statusCode).json(body);
}
// Express error middleware with headersSent guard (required pattern)
app.use((err, req, res, next) => {
if (res.headersSent) {
// Response already started — just close the connection
return next(err);
}
res.status(err.status || 500).json({
error: err.message || 'Internal Server Error'
});
});
// Async route with multiple failure points
app.get('/report', async (req, res, next) => {
try {
const [users, orders] = await Promise.all([
getUsers(),
getOrders()
]);
if (res.headersSent) return;
res.json({ users, orders });
} catch (err) {
if (!res.headersSent) next(err);
}
});
Safe Express Patterns
Pattern A – return on every branch
app.put('/item/:id', async (req, res, next) => {
const { id } = req.params;
if (!id || isNaN(Number(id))) {
return res.status(400).json({ error: 'Invalid id' });
}
let item;
try {
item = await db.findById(id);
} catch (err) {
return next(err); // delegate to error middleware
}
if (!item) {
return res.status(404).json({ error: 'Not found' });
}
try {
const updated = await db.update(id, req.body);
return res.json(updated);
} catch (err) {
return next(err);
}
});
Pattern B – single return point via async/await + centralized error handling
// Wrap all routes with an async error-catching wrapper
const asyncHandler = fn => (req, res, next) =>
Promise.resolve(fn(req, res, next)).catch(next);
app.put('/item/:id', asyncHandler(async (req, res) => {
const id = Number(req.params.id);
if (!id) return res.status(400).json({ error: 'Invalid id' });
const item = await db.findById(id);
if (!item) return res.status(404).json({ error: 'Not found' });
const updated = await db.update(id, req.body);
res.json(updated);
}));
// Any thrown error is caught by .catch(next) and forwarded to error middleware
Pattern C – express-async-errors package (zero boilerplate)
// Install: npm install express-async-errors
// Add this import once at the top of your app entry point:
require('express-async-errors');
// After this, async errors are automatically forwarded to next() — no try/catch needed
app.get('/data', async (req, res) => {
const data = await fetchData(); // if this throws, Express catches it automatically
res.json(data);
});
Debugging Checklist
- Read the stack trace — find the two lines in your own code: the first response call (which succeeded) and the second (which threw). Start there.
- Add
returnbefore everyres.send(),res.json(),res.redirect(), andres.render()call in the affected route handler. - Add
returnafter everynext()andnext(err)call in middleware functions. - Audit every
catchblock — confirm it either callsnext(err)or sends a response, never both. - Search for
setTimeout,.then(, event listeners, and database callbacks inside route handlers — each one must start withif (res.headersSent) return;. - Check error middleware — it must guard with
if (res.headersSent) return next(err);as the very first line. - Temporarily add
console.trace('response sent')inside each response call to capture the full call stack in the logs and identify which code path fires first. - For Fastify: confirm async route handlers either
returnthe value or callreply.send()— not both. - For raw
http.createServer: confirm allres.setHeader()andres.writeHead()calls appear before anyres.write()orres.end(). - Run the route under load (e.g. with
autocannonork6) to expose timing-dependent bugs that only appear under concurrent requests.
ERR_HTTP_HEADERS_SENT error hides the
bug. The underlying double-send still happened — one of the two responses was silently
dropped. Fix the root cause (missing return or a race condition) rather than
swallowing the error.
Frequently Asked Questions
What is ERR_HTTP_HEADERS_SENT?
ERR_HTTP_HEADERS_SENT is a Node.js error thrown when code attempts to send HTTP headers or a response body after the HTTP response has already been sent to the client. Once res.end() is called (directly or via res.send(), res.json(), res.redirect(), or res.render() in Express), the response is sealed. Any further attempt to write to it triggers this error.
What causes ERR_HTTP_HEADERS_SENT?
The most common causes are:
- Missing
returnbeforeres.send()/res.json()in an Express route handler - Calling
next()more than once in the same middleware function - A
catchblock calling bothres.send()andnext(err) - An async callback (
setTimeout, Promise, DB query) resolving after the response was already sent - Calling
res.setHeader()afterres.end()in raw Node.js http code - Fastify async handler both returning a value and calling
reply.send()
How do I fix ERR_HTTP_HEADERS_SENT in Express?
Add return before every response call in your route handlers: return res.json({ error: 'not found' }). This ensures function execution stops when a response is sent. Also add return after every next() call in middleware, and fix any catch blocks that call both res.send() and next(err).
How do I use res.headersSent to prevent ERR_HTTP_HEADERS_SENT?
res.headersSent is true once any response data has been sent. Use it as a guard in async callbacks and error handlers:
setTimeout(() => {
if (res.headersSent) return; // already responded
res.status(503).json({ error: 'Timeout' });
}, 5000);
// In error middleware:
app.use((err, req, res, next) => {
if (res.headersSent) return next(err);
res.status(500).json({ error: err.message });
});
Why does ERR_HTTP_HEADERS_SENT happen in production but not locally?
Race conditions caused by timing-sensitive async operations usually only appear under load. Locally, requests are processed sequentially so a slow database query always resolves before a timeout fires. In production, under concurrent load, a slow async operation from one request path can complete after another code path has already sent a timeout or error response. The fix is to always guard async callbacks with if (res.headersSent) return; and use clearTimeout() to cancel timers when a result arrives.
How do I fix ERR_HTTP_HEADERS_SENT caused by double next() calls?
Add return after every next() call in your middleware:
// Before (broken):
function auth(req, res, next) {
if (!req.user) {
next(new Error('Unauthorized'));
// code continues here!
}
next(); // called a second time for unauthorized requests
}
// After (fixed):
function auth(req, res, next) {
if (!req.user) {
return next(new Error('Unauthorized'));
}
return next();
}
How do I fix ERR_HTTP_HEADERS_SENT in Fastify?
In Fastify, async route handlers automatically send the return value as the response. If you also call reply.send() in the same handler, Fastify tries to send twice. Fix: either return the value (and never call reply.send()), or call reply.send() and return nothing. Check reply.sent before sending in defensive code.
How do I fix ERR_HTTP_HEADERS_SENT in Next.js API routes?
In Next.js API routes, the same rules apply as Express. Add return before every res.json(), res.send(), and res.redirect() call:
// pages/api/user.js
export default async function handler(req, res) {
if (req.method !== 'GET') {
return res.status(405).json({ error: 'Method Not Allowed' });
}
const user = await getUser(req.query.id);
if (!user) {
return res.status(404).json({ error: 'Not found' });
}
return res.json(user);
}
Related Errors
EPIPE: broken pipe— the client disconnected before the response was fully sent; often occurs alongside double-send bugsEADDRINUSE: address already in use— server startup error; separate from response errors but common in the same Express/HTTP server contextUnhandledPromiseRejection— if an async route handler throws after a response was sent and the error is not caught, it surfaces as an unhandled promise rejection