ExpressJS

Fix “Cannot set headers after they are sent to the client” in Express

client text on card
On this page
  1. 1. Missing return after sending a response
  2. 2. Calling next() after responding
  3. 3. Sending inside a loop or a callback that runs more than once
  4. 4. Mixing callbacks and promises
  5. 5. The error handler responds after a response already started
  6. How to find the line that sends twice
  7. What “headers sent” means at the HTTP level
  8. A debugging helper that shows both responses
  9. Write handlers that can only respond once
  10. Guard clauses with return
  11. Return data from helpers, respond in one place
  12. Async pitfalls that cause double responses
  13. Middleware order matters
  14. A real-world example: the login route
  15. Frequently asked questions
  16. Oh hi there 👋It’s nice to meet you.
  17. Sign up to receive awesome content in your inbox.

Your Express API works, but the terminal shows this and sometimes the app crashes:

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

Every HTTP request gets exactly one response. Once you call res.send(), res.json(), res.redirect() or res.end(), the headers are gone and the response is finished. This error appears when your code tries to respond a second time. Here are the causes, from most to least common.

1. Missing return after sending a response

Calling res.json() does not stop your function. The code below keeps running:

// Bug: both responses run when the user is not found
app.get('/api/users/:id', async (req, res) => {
  const user = await User.findById(req.params.id);
  if (!user) {
    res.status(404).json({ message: 'User not found' });
  }
  res.json(user); // second response: ERR_HTTP_HEADERS_SENT
});

Add return so the function exits after the first response:

app.get('/api/users/:id', async (req, res) => {
  const user = await User.findById(req.params.id);
  if (!user) {
    return res.status(404).json({ message: 'User not found' });
  }
  res.json(user);
});

Making return res… a habit in every early exit prevents most of these bugs.

2. Calling next() after responding

In middleware, either send a response or call next(), never both:

// Bug
function requireAuth(req, res, next) {
  if (!req.headers.authorization) {
    res.status(401).json({ message: 'Login required' });
  }
  next(); // the route handler responds again
}

// Fixed
function requireAuth(req, res, next) {
  if (!req.headers.authorization) {
    return res.status(401).json({ message: 'Login required' });
  }
  next();
}

3. Sending inside a loop or a callback that runs more than once

A response inside forEach, an event listener or a callback can run several times:

// Bug: responds once per item
items.forEach((item) => {
  if (!item.price) res.status(400).json({ message: 'Missing price' });
});

// Fixed: check first, respond once
const invalid = items.find((item) => !item.price);
if (invalid) {
  return res.status(400).json({ message: 'Missing price', item: invalid.name });
}

4. Mixing callbacks and promises

Code that both passes a callback and awaits a promise can respond twice, once in each path. Pick one style. With Mongoose and modern drivers, use async/await everywhere and remove callbacks.

5. The error handler responds after a response already started

If an error happens while a response is streaming, your error handler can’t send a new one. Check res.headersSent and hand the error to Express’s built-in handler, which closes the connection safely:

app.use((err, req, res, next) => {
  if (res.headersSent) {
    return next(err);
  }
  console.error(err);
  res.status(err.status || 500).json({ message: 'Something went wrong' });
});

How to find the line that sends twice

The stack trace points at the second response. Look at the function it names, then search that route for every res. call and every next(). Ask for each one: can the code continue after this line? If yes, add return.

If you upgraded to Express 5, async errors now reach your error handler automatically, which removes a lot of try/catch code where double responses used to hide. See how to migrate to Express 5.

What “headers sent” means at the HTTP level

An HTTP response has two parts: the head (status code and headers such as Content-Type and Set-Cookie) and the body (your JSON or HTML). The head always travels first. The moment Node writes the first byte of the body, the head is sent over the network and can’t be changed anymore.

That is why the error mentions headers even when your code only calls res.json(). A second res.json() tries to set Content-Type and a status code again, but they were sent with the first response. Calls such as res.status(), res.cookie() and res.set() after the first response fail for the same reason.

A debugging helper that shows both responses

The stack trace shows where the second response happened, but not the first. During development, this small middleware records where the first response came from, so you can see both places at once:

// dev-only: find double responses
function traceResponses(req, res, next) {
  const originalJson = res.json.bind(res);
  const originalSend = res.send.bind(res);
  let firstStack = null;

  function wrap(fn) {
    return (...args) => {
      if (res.headersSent && firstStack) {
        console.error(`Double response on ${req.method} ${req.originalUrl}`);
        console.error('First response was sent here:\n' + firstStack);
      }
      if (!firstStack) firstStack = new Error().stack.split('\n').slice(2, 6).join('\n');
      return fn(...args);
    };
  }

  res.json = wrap(originalJson);
  res.send = wrap(originalSend);
  next();
}

if (process.env.NODE_ENV !== 'production') app.use(traceResponses);

Remove it once you have found the bug. It exists only to point you at the right lines quickly.

Write handlers that can only respond once

Two habits make this error almost impossible to write in the first place.

Guard clauses with return

Check every failure case at the top of the handler, each with return, and keep the success response as the last line:

app.patch('/api/posts/:id', requireAuth, async (req, res) => {
  const post = await Post.findById(req.params.id);
  if (!post) return res.status(404).json({ message: 'Post not found' });
  if (String(post.author) !== req.user.sub) return res.status(403).json({ message: 'Not your post' });
  if (!req.body.title) return res.status(400).json({ message: 'Title is required' });

  post.title = req.body.title;
  await post.save();
  res.json(post); // the only success path
});

Return data from helpers, respond in one place

Helper functions shouldn’t touch res. Let them return data or throw an error, and let the route handler decide what to send. When three different helpers can each call res.json(), double responses become very likely.

// Risky: the helper responds, and so might the route
async function loadUser(id, res) {
  const user = await User.findById(id);
  if (!user) res.status(404).json({ message: 'Not found' });
  return user;
}

// Safer: the helper throws, the error handler responds
class HttpError extends Error {
  constructor(status, message) { super(message); this.status = status; }
}
async function loadUser(id) {
  const user = await User.findById(id);
  if (!user) throw new HttpError(404, 'User not found');
  return user;
}

Async pitfalls that cause double responses

A forgotten await. If you call an async function without await and it responds later, your handler may have already responded. Linters can catch this: turn on the no-floating-promises rule in typescript-eslint.

Responding inside Promise.all callbacks. Run the work in parallel, wait for all of it, then respond once with the combined result.

Timeouts that also respond. Code that sends “request timed out” after a few seconds must check res.headersSent first, or cancel the timer when the normal response goes out.

const timer = setTimeout(() => {
  if (!res.headersSent) res.status(504).json({ message: 'Timed out' });
}, 10_000);
res.on('finish', () => clearTimeout(timer));

Middleware order matters

Express runs middleware in the order you register it. A response in early middleware ends the request for everything after it, so later middleware must not respond again. A typical safe order:

  1. Security and parsing: helmet(), cors(), express.json().
  2. Authentication middleware that either calls next() or returns a 401.
  3. Validation middleware that either calls next() or returns a 400.
  4. Your routes.
  5. A 404 handler for unmatched routes.
  6. The error handler, with four arguments, last of all.

Each step either passes the request on or ends it, never both.

A real-world example: the login route

Login routes are a classic place for this bug, because they have several ways to fail. Here is a version with three separate problems, followed by the fixed one.

// Three bugs: missing returns, callback plus await, and a response in a helper
app.post('/api/login', async (req, res) => {
  if (!req.body.email) res.status(400).json({ message: 'Email required' });
  const user = await User.findOne({ email: req.body.email });
  if (!user) res.status(401).json({ message: 'Wrong email or password' });
  bcrypt.compare(req.body.password, user.passwordHash, (err, ok) => {
    if (!ok) res.status(401).json({ message: 'Wrong email or password' });
  });
  res.json({ token: makeToken(user) });
});
// Fixed: early returns, one async style, one success response
app.post('/api/login', async (req, res) => {
  const { email, password } = req.body;
  if (!email || !password) return res.status(400).json({ message: 'Email and password required' });

  const user = await User.findOne({ email }).select('+passwordHash');
  const ok = user && (await bcrypt.compare(password, user.passwordHash));
  if (!ok) return res.status(401).json({ message: 'Wrong email or password' });

  res.json({ token: makeToken(user) });
});

The buggy version also crashes when the user doesn’t exist, because it reads user.passwordHash on null. Double responses and crashes often travel together, and the same fix, returning early, solves both. A full, secure login flow is in JWT authentication in Node.js and Express.

Frequently asked questions

Does res.send() end the function?

No. It ends the HTTP response, but your JavaScript keeps running until the function returns. That is why return res.send(…) is the safe pattern.

Why does the client get the right data even though the error appears?

The first response already reached the client. The error is about the second attempt, which fails. It still matters, because the extra code may run database writes twice or crash your process.

Can I set a header after res.json()?

No. Set every header, cookie and status code before you send the body.

Can this error crash my server?

In Express 5 and in Express 4 with proper error handling, it is caught and logged. But if the second response happens inside a timer or event callback outside Express’s control, the thrown error can crash the process. Fix the cause rather than relying on luck.

What is res.headersSent?

A boolean that is true once the response head has been sent. Check it in error handlers and timeouts before you try to respond.

logo

Oh hi there 👋
It’s nice to meet you.

Sign up to receive awesome content in your inbox.

We don’t spam! Read our privacy policy for more info.

Saurabh

Full-stack developer writing practical guides on the MERN stack, AI and DevOps. Every code sample on this site is tested before it is published.