BugMojoBugMojoBugMojo
FeaturesPricingBlogHelpAbout
Add to ChromeLog inGet started
BugMojoBugMojo

Bug reports that actually help fix bugs — capture, replay, share.

A product of Softech Infra.

Product

  • Features
  • Pricing
  • Browser extension
  • Get started
  • Log in

Resources

  • Help & guides
  • Blog
  • Compare
  • Glossary

Company

  • About
  • Contact
  • Security
  • Privacy
  • Terms
  • Sitemap
© 2026 BugMojo. All rights reserved.
AllGuidesEngineeringPlaybooksCompareGlossaryAlternativesBy roleBug tracking by framework
  1. Home
  2. Blog
  3. Guides
  4. Fix "Cannot Set Headers After They Are Sent" (Node/Express)
Guide

Fix "Cannot Set Headers After They Are Sent" (Node/Express)

It almost always means one handler tried to respond twice. Here is the mental model, the six patterns that cause it, and the fixes — starting with the missing return that catches nearly everyone.

Hrishikesh BaidyaHrishikesh Baidya·Jul 16, 2026·11 min read
Guides
Isometric line-art timeline of an HTTP response already sent, then a second write attempting to set headers and cracking into an error, lime on a dark canvas
TL;DR
  • What it means: your code tried to write to an HTTP response that was already finalized. One request gets exactly one response.
  • The mental model: res.send(), res.json(), res.end(), and res.redirect() all finalize the response. Anything that writes after them throws ERR_HTTP_HEADERS_SENT.
  • The #1 cause: forgetting to return after responding, so execution falls through and a second branch responds too.
  • Fix it: always return res.json(...), ensure exactly one response path, and guard error handlers with if (res.headersSent) return next(err).

Error [ERR_HTTP_HEADERS_SENT]: Cannot set headers after they are sent to the client is the backend counterpart to the frontend's most-hated crashes: alarming to read, but nearly always caused by one small structural mistake. It does not mean your headers are malformed or your client is misbehaving. It means a single handler tried to answer the same request twice, and Node refused the second answer.

What the error actually means

ERR_HTTP_HEADERS_SENT is thrown when your code writes to an HTTP response after it has already been sent. An HTTP response has one status line and header block, flushed before the body; once that happens the response is committed and closed. A second res.send(), header set, or redirect has no open response to modify, so Node throws.

That ordering is not a Node quirk — it is how HTTP itself is framed on the wire. Per MDN's reference on HTTP messages, a response is a start-line (the status), then a set of headers, then an empty line marking "metadata complete," and only then an optional body. Headers are physically transmitted before the body, so once a single byte of body has gone out, there is no going back to add or change a header. Node enforces exactly this: the http docs state that response.headersSent becomes true once headers are sent and that they then "become immutable and cannot be changed." The official error reference defines ERR_HTTP_HEADERS_SENT as "an attempt was made to add more headers after the headers had already been sent."

The mental model is worth internalizing because it makes every variant of this bug obvious. Sending an HTTP response is a one-way door. res.send(), res.json(), res.end(), and res.redirect() all walk through it: they set the status, flush the headers, write the body, and close the connection. Note that even res.write() counts — Node's docs confirm that if writeHead() was not called first, the first write() flushes the headers automatically, tripping the same one-way latch. After that door closes, the response object still exists in memory, but it is inert — every method that would write to it now throws instead. So the question is never "why are my headers broken?" It is always "which second write ran after the first response?"

The six patterns that cause it

The examples below are Express-centric because that is where most people hit this, but the exact same rule applies to the raw Node http module: after res.writeHead() or res.end(), the response is committed and any further write throws.

1. Forgetting to return after responding — the most common cause by a wide margin. Calling res.json() sends the response but does not stop your function; the next line still runs.

1-missing-return.jsjavascript
// BROKEN: res.json() sends, but execution keeps going
app.get('/user/:id', async (req, res) => {
  const user = await db.findUser(req.params.id);
  if (!user) {
    res.status(404).json({ error: 'Not found' });
    // no return — code falls through to the line below
  }
  res.json(user); // runs even when user was null → throws
});

// FIXED: return exits the handler the moment you respond
app.get('/user/:id', async (req, res) => {
  const user = await db.findUser(req.params.id);
  if (!user) {
    return res.status(404).json({ error: 'Not found' });
  }
  return res.json(user);
});
Key takeaway

If you fix nothing else, adopt the habit of writing return res.json(...) (and return res.send(...), return res.redirect(...)) everywhere. The return costs nothing when the response was the last statement anyway, and it makes the missing-return class of this bug structurally impossible.

2. Responding inside a loop or a callback that runs more than once. The first iteration responds; the second one throws.

2-responding-in-a-loop.jsjavascript
// BROKEN: res.json() runs once per matching item
app.get('/search', (req, res) => {
  items.forEach((item) => {
    if (item.match(req.query.q)) {
      res.json(item); // throws on the 2nd match
    }
  });
});

// FIXED: collect, then respond exactly once
app.get('/search', (req, res) => {
  const matches = items.filter((item) => item.match(req.query.q));
  return res.json(matches);
});

3. Responding in both a branch and after it — a missing else or early return. Structurally identical to pattern 1, but worth calling out because it hides inside longer if/else if chains where the fall-through is less obvious.

3-branch-and-fallthrough.jsjavascript
// BROKEN: the success case sends, then the tail sends again
app.post('/login', (req, res) => {
  if (isValid(req.body)) {
    res.json({ token: sign(req.body) });
  }
  res.status(401).json({ error: 'Invalid credentials' }); // always runs
});

// FIXED: one response per path, guaranteed by returns
app.post('/login', (req, res) => {
  if (isValid(req.body)) {
    return res.json({ token: sign(req.body) });
  }
  return res.status(401).json({ error: 'Invalid credentials' });
});

4. An async race — a callback and a timeout (or two promises) both respond. This one is nastier because it may only fail intermittently, under load, when the timing lines up.

4-async-race.jsjavascript
// BROKEN: both the timeout and the query can respond
app.get('/slow', (req, res) => {
  const timer = setTimeout(() => {
    res.status(504).json({ error: 'Timed out' });
  }, 3000);

  db.query().then((rows) => {
    res.json(rows); // if the timeout already fired, this throws
  });
});

// FIXED: clear the timer and guard so exactly one path wins
app.get('/slow', (req, res) => {
  let done = false;
  const finish = (fn) => { if (!done) { done = true; fn(); } };

  const timer = setTimeout(() => {
    finish(() => res.status(504).json({ error: 'Timed out' }));
  }, 3000);

  db.query().then((rows) => {
    clearTimeout(timer);
    finish(() => res.json(rows));
  });
});

5. Middleware that calls next() and also sends a response. Express middleware has one job per invocation. As the Express guide on writing middleware puts it: "If the current middleware function does not end the request-response cycle, it must call next() to pass control to the next middleware function. Otherwise, the request will be left hanging." The word to notice is either — end the cycle by responding, or pass control with next(), never both. Doing both means your middleware responds and then a downstream handler responds again.

5-middleware-next-and-send.jsjavascript
// BROKEN: responds AND calls next() → the route responds too
function requireAuth(req, res, next) {
  if (!req.user) {
    res.status(401).json({ error: 'Unauthorized' });
  }
  next(); // runs even after the 401 above
}

// FIXED: return after responding; only call next() to continue
function requireAuth(req, res, next) {
  if (!req.user) {
    return res.status(401).json({ error: 'Unauthorized' });
  }
  return next();
}

6. Responding in an error handler after a response was already sent. If a route streams a partial response and then throws, Express hands the error to your error-handling middleware — but the headers are already gone. This is the one case where you genuinely cannot restructure your way out, and where you should check res.headersSent.

Guarding error handlers with res.headersSent

res.headersSent is a boolean on the response object: false until the headers are flushed, true afterward. Reading it lets you decide whether it is still safe to respond. The canonical use is the Express error handler. Express's own error-handling guide is explicit that you must delegate to the default handler once headers are out: if you call next(err) after the response has started, the default handler closes the connection and fails the request cleanly — whereas your custom handler trying to res.status(500).json(...) would throw a second, more confusing error that masks the original one.

6-error-handler-guard.jsjavascript
// The four-arg signature marks this as Express error middleware
app.use((err, req, res, next) => {
  // If a partial response already went out, don't write again —
  // hand off to Express's built-in handler, which closes the socket.
  if (res.headersSent) {
    return next(err);
  }
  res.status(500).json({ error: 'Something went wrong' });
});
Watch out

res.headersSent is a safety net, not a design tool. Sprinkling if (res.headersSent) checks throughout normal route code hides the real bug — a handler that responds on more than one path — instead of fixing it. Reserve the guard for error handlers and other places where a prior response is legitimately possible and out of your control.

Cause → fix at a glance

Every row below is the same underlying defect — a second write to a finalized response — but the symptom and the smallest correct fix differ. Use this as a triage table: match the shape of your bug on the left, apply the fix on the right.

FeatureWhat triggers itThe minimal fix
Missing return after respondingres.json() sends, but execution falls through to another responsePrefix with return: return res.json(...)
Responding inside a loop / repeated callbackforEach or an emitter fires the response more than onceCollect first, then respond exactly once after the loop
Branch plus fall-throughA success branch responds, then a shared tail responds tooGive each branch its own return res... — no shared tail
Async race (timeout vs. query)Two callbacks each respond when timing lines upSingle-flight guard (a done flag) + clearTimeout()
Middleware calls next() and respondsAuth guard sends 401 but still calls next()return res.status(401)...; only call next() to continue
Error handler after a partial responseRoute streams, then throws; handler writes againif (res.headersSent) return next(err)
The six common triggers of ERR_HTTP_HEADERS_SENT and the minimal fix for each.

The async angle, and what Express 5 changes

Most stubborn cases of this error live in asynchronous code, because that is where two response paths can exist without being visible on the same screen. The durable fix for async handlers is the standard Express pattern: await your work inside a try block, respond once on success, and in catch forward the error with next(error) rather than responding a second time. Per Better Stack's Express error-handling guide, wrapping async operations in try/catch and forwarding with next(error) is what keeps a rejected promise from either crashing the process as an unhandled rejection or racing your success path to the response.

Express 5 tightens this considerably. Better Stack notes that Express 5 "improves error handling by automatically propagating errors from async functions to the error handler," so an error thrown inside an async route is caught for you and the explicit next(error) call is no longer required. That removes one common way to accidentally double-respond — but it does not remove the others. A missing return, a middleware that both sends and calls next(), or a racing timeout will still throw ERR_HTTP_HEADERS_SENT on Express 5 exactly as on Express 4. The framework can catch your thrown errors; it cannot decide which of your two responses you meant.

Caveats — when this isn't the whole story

A few honest limits. First, res.headersSent tells you headers are gone, but it cannot un-send a partial body — by the time it reads true, some bytes may already be on the wire, so a streamed download that fails midway can leave the client with a truncated response no guard can repair. The guard prevents a second thrown error; it does not make the first response whole. Second, the missing-return habit is necessary but not sufficient: a handler can still double-respond across two separate async callbacks that each own a valid response path, and no amount of return in one of them constrains the other. Third, frameworks layered on Express — or your own response helpers that call res.send() internally — can hide the real second write, so a stack trace that points at library code usually means the offending call is a level up in your code. As the Honeybadger guide to Node.js error handling stresses, centralizing response and error logic in one place beats scattering guards, precisely because it makes "exactly one response path" something you can see rather than hope for.

The one rule that prevents all six

Every pattern above is the same mistake wearing a different costume: two writes to one response. So the fix is a single design rule — each request handler must have exactly one response path, and it must return the moment it responds. In practice that means three habits. First, prefix every response call with return, so responding and exiting are one action. Second, in branching code, make each branch responsible for its own return res... rather than letting execution fall through to a shared tail. Third, in async code, funnel all outcomes — success, timeout, error — through a single guard (like the finish() helper above) so only the first one wins and the rest are no-ops.

When you do hit the error, read the stack trace rather than guessing. The frame that throws points at the second write, and the fix is almost always to add a return a few lines up or to collapse two response paths into one. If you are unsure how to read that trace back to the offending handler, our guide on reading a JavaScript stack trace walks through it frame by frame. And because so many of these bugs live in unawaited promises and racing callbacks, the patterns in fixing uncaught-in-promise errors pair naturally with this one.

Tip

When this only shows up intermittently in production — the async-race version — the hard part is knowing which handler double-responded and what the request looked like when it did. Capturing the full request flow for the failing call, rather than re-deriving it from a lone stack frame, shows you the exact path that answered twice. That is the general lesson: the bug is a timing story, and you fix a timing story fastest when you can replay it.

None of this requires new libraries or clever abstractions. ERR_HTTP_HEADERS_SENT is Node telling you, precisely and early, that your control flow lets a request be answered more than once. Tighten the control flow — one path, one response, a return every time — and the error disappears along with a whole category of subtler bugs that were only waiting for the right timing to surface.

⁓ ⁓ ⁓
See which handler responded twice

Install the free BugMojo extension to capture the full request flow — replay, console, and network — for the exact call that failed, so you find the double-response instead of guessing from one stack frame.

Install the free extension

Frequently asked questions

Frequently asked questions

Sources

  1. Node.js Errors — ERR_HTTP_HEADERS_SENT: an attempt was made to add more headers after the headers had already been sent — Node.js (2026)
  2. Node.js HTTP — response.headersSent and response.writeHead(): the response lifecycle and when headers become immutable — Node.js (2026)
  3. Writing middleware for use in Express apps — a middleware function either ends the request-response cycle or calls next(), not both — Express (2026)
  4. Error Handling in Express — delegating to the default handler with res.headersSent when a response has already started — Express (2026)
  5. HTTP Messages — MDN reference: the structure of a request/response and that status and headers are sent before the body — MDN Web Docs (2026)
  6. Express Error Handling Patterns — async try/catch, next(error), and Express 5's automatic async error propagation — Better Stack (2026)
  7. A comprehensive guide to error handling in Node.js — centralizing response and error logic — Honeybadger (2026)
Share:
Hrishikesh Baidya
Hrishikesh Baidya· Chief Technology Officer

Hrishikesh Baidya is the CTO at Softech Infra. He is drawn to architecture that is invisible — systems that simply work — and leads the engineering behind BugMojo.

On this page

  • What the error actually means
  • The six patterns that cause it
  • Guarding error handlers with res.headersSent
  • Cause → fix at a glance
  • The async angle, and what Express 5 changes
  • Caveats — when this isn't the whole story
  • The one rule that prevents all six

Get bug-tracking insights, weekly.

Engineering deep-dives, QA playbooks, and honest tool comparisons. No spam — unsubscribe in one click.