Skip to content

Fix: Cannot set headers after they are sent to the client

FixDevs ·

Part of:  JavaScript & TypeScript Errors

Quick Answer

Fix Node.js/Express 'Cannot set headers after they are sent to the client', usually a missing return, double next(), or a duplicate response.

The line the stack trace points to is almost never the guilty one. I’ve spent more time than I’d like staring at the res.json() call the error names, when the actual bug was three branches earlier, a missing return that let execution fall through to a second response. Once you know the error only ever means “this handler tried to respond twice,” the fix is fast. Finding which of the two attempts was the accidental one is the entire job.

Error [ERR_HTTP_HEADERS_SENT]: Cannot set headers after they are sent to the client

Your Node.js or Express server throws this, usually a moment after a request appears to succeed:

node:_http_outgoing:658
    throw new ERR_HTTP_HEADERS_SENT('set');
    ^

Error [ERR_HTTP_HEADERS_SENT]: Cannot set headers after they are sent to the client
    at ServerResponse.setHeader (node:_http_outgoing:658:11)
    at ServerResponse.header (/app/node_modules/express/lib/response.js:794:10)
    at ServerResponse.send (/app/node_modules/express/lib/response.js:174:12)
    at ServerResponse.json (/app/node_modules/express/lib/response.js:278:15)

It rarely crashes the request outright, the first response already went out fine. Instead it crashes on the second attempt, often from an unrelated-looking piece of code that runs later: a database callback, a catch block, a timeout, a second middleware in the chain.

Why this happens

HTTP requires headers to go out before the body, and once the first byte of a response leaves the socket, the headers are locked. Node tracks this with a simple boolean, res.headersSent, and any call that tries to set a header or start a new response after that flips to true, res.writeHead(), res.setHeader(), or the header-writing side of res.send() / res.json() / res.redirect(), throws ERR_HTTP_HEADERS_SENT immediately.

So the error is never really about headers. It is Node telling you that something in this request tried to respond a second time. Your handler function returning does not end the request the way returning from a normal function ends its caller. If a callback, a .then(), or a later line still runs after you already called res.send(), whatever it does will run against a response that is already gone.

Add the missing return

This is the single most common cause. Without a return, control keeps running into a second response call after an early one:

// BUG: no return after the first res.send(), so ANY code below still runs
app.get('/user/:id', (req, res) => {
  const user = findUser(req.params.id);

  if (!user) {
    res.status(404).send('Not found');
  }

  res.json(user);   // still runs even when user is undefined, and even when it isn't
});
// FIX: return stops execution at the first response
app.get('/user/:id', (req, res) => {
  const user = findUser(req.params.id);

  if (!user) {
    return res.status(404).send('Not found');
  }

  res.json(user);
});

Note that the bug above fires on every request, not only the 404 case, because nothing stops the second res.json() from running after the first response when a user is found too. Whenever you see this error, grep the handler for every res. call and check that no two of them can run on the same request.

Guard async code that responds after an error

async/await and promises are the second most common source, because the response can be sent, and then a later await in the same function throws, and a catch block tries to respond again:

// BUG: responds once on success, then the catch responds again if logging fails
app.post('/orders', async (req, res) => {
  try {
    const order = await createOrder(req.body);
    res.json(order);

    await logOrderCreated(order);   // if this throws, execution jumps to catch below
  } catch (err) {
    res.status(500).json({ error: err.message });   // ERR_HTTP_HEADERS_SENT
  }
});
// FIX: nothing that can fail runs after the response is sent
app.post('/orders', async (req, res) => {
  try {
    const order = await createOrder(req.body);
    await logOrderCreated(order);
    res.json(order);
  } catch (err) {
    res.status(500).json({ error: err.message });
  }
});

When the response genuinely must go out before some trailing work (an audit log, a webhook), guard the trailing work with its own try/catch so a failure there cannot reach the response-sending catch block at all:

app.post('/orders', async (req, res) => {
  const order = await createOrder(req.body);
  res.json(order);

  try {
    await logOrderCreated(order);
  } catch (err) {
    console.error('Order logging failed:', err);   // does not touch res
  }
});

Stop calling next() after you’ve already responded

Calling next() hands the request to the next middleware in the chain. If you already sent a response and then call next() anyway, whatever runs next has no way to know that, and often responds itself:

// BUG: next() runs after res.send(), so the next middleware responds too
app.use((req, res, next) => {
  if (isBlocked(req.ip)) {
    res.status(403).send('Forbidden');
  }
  next();   // always runs, even for blocked requests
});
// FIX: only call next() on the path that did not already respond
app.use((req, res, next) => {
  if (isBlocked(req.ip)) {
    return res.status(403).send('Forbidden');
  }
  next();
});

Check res.headersSent in your error-handling middleware

Express’s own convention for error-handling middleware is documented precisely for this reason. When more than one error handler is registered, or an error surfaces after streaming has already started, your custom handler must check res.headersSent and delegate rather than try to send its own response:

// Express's own documented pattern
function errorHandler(err, req, res, next) {
  if (res.headersSent) {
    return next(err);   // delegate to Express's default handler instead of responding again
  }
  res.status(500);
  res.render('error', { error: err });
}

app.use(errorHandler);

Skipping this check is exactly why apps with more than one error-handling middleware, or a global error handler layered on top of route-level try/catch blocks, hit this constantly: the second handler in the chain has no way to know the first one already responded, unless it checks.

Find the duplicate route or middleware registration

If the handler code itself looks correct and only calls a response method once, check whether the handler is registered more than once. A duplicate app.use() from a hot-reload cycle, a router required and mounted twice, or a wildcard route (app.use('/', ...)) that also matches a more specific route further down, all cause two separate handlers to run for the same request, and the second one to throw this error the moment it tries to respond:

// BUG: router mounted twice, so both copies handle every matching request
const userRouter = require('./routes/user');
app.use('/api', userRouter);
// ... later, elsewhere in the same file or a different one ...
app.use('/api', userRouter);

List the registered routes to confirm, or add a one-time log statement inside the suspect handler and check whether it fires twice per request. I’ve hit this specific version after a hot-reload tool re-required a router file without tearing down the previous registration first, so the handler ran twice on every request until the dev server was fully restarted.

Don’t write to a stream after res.end()

Streaming responses have the same rule at the stream level. Writing after the stream has ended throws a related but distinct error:

// BUG: a write scheduled after end() already ran
res.write('first chunk');
res.end();
setTimeout(() => res.write('oops, too late'), 100);   // ERR_STREAM_WRITE_AFTER_END

Track whether the response has ended before any code that writes to it asynchronously, the same res.headersSent check works here too, since res.end() also sends headers if they have not gone out yet.

Still not working?

A request timeout middleware and your own handler both responded. Packages like connect-timeout send their own response when a request runs too long, but your original handler keeps running in the background and eventually tries to respond too, unaware the client already got a timeout response. In my experience this is the hardest variant to catch in local testing, since it only shows up under real load or against a genuinely slow downstream call, never on a fast dev machine hitting a mocked API. Check for any timeout middleware in the stack and make sure your handler checks whether the request already timed out before it responds.

The client disconnected mid-request. If the client closes the connection before your handler finishes, later code that still tries to respond will hit a related failure. Listen for the close event and skip the response if it already fired:

app.get('/report', async (req, res) => {
  let aborted = false;
  req.on('close', () => { aborted = true; });

  const data = await buildReport();
  if (!aborted) res.json(data);
});

A retried operation responds once per attempt. Retry logic around a database call or an external API request can accidentally call res.send() inside each retry attempt instead of only after the final one succeeds or fails. Move the response call outside the retry loop entirely.

This is not the same error as Next.js’s “API resolved without sending a response.” That one is the opposite problem, a handler that never calls a response method at all. See Fix: Next.js API route not working if that is what you are actually seeing.

An unhandled rejection elsewhere in the process triggers a crash after a response already went out. If your process-wide unhandledRejection handler tries to send an HTTP response of its own, it will hit this same error for any request that had already completed. See Fix: Node.js Unhandled Rejection Crash for handling those at the right layer instead of inside a request handler.

For related Express and middleware issues, see Fix: Express Middleware Not Working, Fix: Express Async Error, and Fix: Express req.body Undefined.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles