Cannot set headers after they are sent to the client
Por que o Express reclama de header depois da resposta enviada, as quatro situações que provocam o ERR_HTTP_HEADERS_SENT e a correção de cada uma.
Cannot set headers after they are sent to the client significa que o código
tentou alterar os cabeçalhos de uma resposta HTTP depois que o envio já havia
começado. No Express, a causa mais comum é ter dois caminhos com res. para a
mesma requisição: o primeiro responde e o segundo continua executando.
Imagine um envelope com endereço do lado de fora e documento dentro. Os headers
são as informações externas; o body é o conteúdo. Depois que o envelope entra
no caminhão, não dá para trocar o endereço. No Node, o primeiro res.write,
res.send, res.json ou res.end pode confirmar os headers; tentar definir
status ou cabeçalho depois disso dispara ERR_HTTP_HEADERS_SENT. Ainda é
possível enviar novos pedaços de body em um streaming, mas não reescrever o que
já foi confirmado.
Todos os exemplos aqui são da API do VetClub, uma clínica veterinária que
agenda consultas. Tudo rodou no Node 24.16.0 com Express 5.2.1, e cada saída
abaixo foi copiada do terminal. Cada causa é um arquivo separado, escutando na
própria porta — por isso o número muda de um curl para o outro.
O stack trace e o código ERR_HTTP_HEADERS_SENT
Esta rota cadastra uma consulta. Ela valida o nome do pet, e é aí que o problema mora:
import express from 'express';
const app = express();
app.use(express.json());
const agenda = [];
app.post('/consultas', (req, res) => {
const { pet, tutor, horario } = req.body;
if (!pet) {
res.status(400).json({ erro: 'informe o nome do pet' });
}
agenda.push({ pet, tutor, horario });
res.status(201).json({ id: agenda.length, pet, horario });
});
app.listen(4571, () => console.log('VetClub ouvindo na porta 4571'));Mandando um agendamento sem o nome do pet:
curl -i -X POST http://localhost:4571/consultas \
-H 'Content-Type: application/json' \
-d '{"tutor":"Marina","horario":"14:30"}'{“erro”:“informe o nome do pet”}
Do lado de quem chamou, está tudo certo: 400, mensagem clara, conexão fechada com educação. O estrago aparece no terminal do servidor:
São dez quadros porque dez é o limite padrão do Node, e essa forma é fixa — vale
decorar. Os quatro primeiros at são sempre os mesmos: setHeader → header →
send → json. Eles são o caminho de dentro do Express e não interessam —
nem eles nem os cinco de baixo, que são o roteador chamando a sua rota. O quadro
que importa é o quinto:
at file:///private/tmp/vetclub/causa1.js:16:19Essa é a segunda resposta, com arquivo, linha e coluna. No exemplo, linha 16
é o res.status(201).json(...). O que o trace não te conta é onde ficou a
primeira — e é por isso que a maior parte do tempo de depuração vai embora
procurando o outro res. na mesma rota.
O erro nem é do Express, aliás. Ele nasce no módulo http do Node:
import http from 'node:http';
const servidor = http.createServer((req, res) => {
res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' });
res.write('Amora — ');
res.write('vacinada\n'); // dois write no mesmo corpo: permitido
res.end();
try {
res.setHeader('X-Clinica', 'VetClub'); // agora não dá mais
} catch (erro) {
console.log('name :', erro.name);
console.log('code :', erro.code);
console.log('msg :', erro.message);
}
});
servidor.listen(4580, () => console.log('http puro na porta 4580'));Repare em dois detalhes. Primeiro: err.code é ERR_HTTP_HEADERS_SENT, e é por
esse código que você identifica o erro em produção, não pela mensagem em inglês.
Segundo: os dois res.write seguidos passaram sem reclamação. O corpo pode
sair em pedaços; o cabeçalho não pode voltar atrás.
Causa 1 — o return que faltou depois do res.json()
Volte à rota do começo. res.status(400).json(...) responde, mas não
interrompe a função. O JavaScript continua na linha seguinte, empurra a consulta
sem pet para dentro da agenda e tenta responder 201.
A correção é uma palavra:
if (!pet) {
- res.status(400).json({ erro: 'informe o nome do pet' });
+ return res.status(400).json({ erro: 'informe o nome do pet' });
}O return não tem nada a ver com o valor devolvido — o Express ignora o retorno
do handler. Ele existe só para parar o resto da função. Com ele, os dois
caminhos passam a funcionar:
for corpo in '{"tutor":"Marina","horario":"14:30"}' \
'{"pet":"Amora","tutor":"Marina","horario":"14:30"}'; do
curl -s -w ' <- %{http_code}\n' -X POST http://localhost:4572/consultas \
-H 'Content-Type: application/json' -d "$corpo"
doneCausa 2 — o next() chamado depois de já ter respondido
A clínica só deixa a recepção listar a agenda. O middleware abaixo verifica o
perfil, devolve 403 para quem não é da recepção — e mesmo assim chama next():
function apenasRecepcao(req, res, next) {
const perfil = req.get('X-Perfil');
if (perfil !== 'recepcao') {
res.status(403).json({ erro: 'somente a recepcao agenda consultas' });
}
next();
}
app.get('/consultas', apenasRecepcao, (req, res) => {
res.json([{ id: 1, pet: 'Amora', horario: '14:30' }]);
});Aqui o stack trace muda de cara, e a diferença é a peça que te salva:
O quadro que denuncia a causa é o oitavo, o único com nome de função sua:
at apenasRecepcao. Leia dali para cima — apenasRecepcao chamou next na
linha 12, o next chamou o handler da linha 16, e o handler tentou responder de
novo. Quando o nome de um middleware seu aparece no trace, ele é o culpado —
respondeu e deixou a fila andar.
A correção é a mesma palavra da causa 1, no lugar certo:
function apenasRecepcao(req, res, next) {
const perfil = req.get('X-Perfil');
if (perfil !== 'recepcao') {
return res.status(403).json({ erro: 'somente a recepcao agenda consultas' });
}
next();
}Batendo na rota sem o cabeçalho e com ele, agora cada requisição recebe uma resposta só, e o log do servidor fica limpo:
Se essa cadeia de next() ainda te parece nebulosa, vale voltar em
middleware no Express antes de seguir.
Causa 3 — o callback assíncrono respondendo em segundo lugar
Esta é a única das quatro que derruba o processo, e a mais difícil de achar, porque as duas respostas moram em linhas que nunca rodam juntas.
A rota busca um prontuário e, para não deixar o app do balcão pendurado, monta
um setTimeout de 150 ms que devolve 504. Só que a busca real leva 300 ms:
function buscarProntuario(id) {
return new Promise((resolve) => {
setTimeout(() => resolve({ id, pet: 'Amora', vacinas: 3 }), 300);
});
}
app.get('/prontuarios/:id', (req, res) => {
setTimeout(() => {
res.status(504).json({ erro: 'o prontuario demorou demais' });
}, 150);
buscarProntuario(req.params.id).then((prontuario) => {
res.json(prontuario);
});
});O cliente recebe o 504 normalmente. Cento e cinquenta milissegundos depois, o
.then acorda e responde de novo:
Error [ERR_HTTP_HEADERS_SENT]: Cannot set headers after they are sent to the client at ServerResponse.setHeader (node:_http_outgoing:647:11) at ServerResponse.header (/private/tmp/vetclub/node_modules/express/lib/response.js:686:10) at ServerResponse.send (/private/tmp/vetclub/node_modules/express/lib/response.js:163:12) at ServerResponse.json (/private/tmp/vetclub/node_modules/express/lib/response.js:252:15) at file:///private/tmp/vetclub/causa3.js:17:9 { code: ‘ERR_HTTP_HEADERS_SENT’ }
Node.js v24.16.0
Note o throw na primeira linha e o Node.js v24.16.0 na última: isso não é um
log, é o processo morrendo. A próxima requisição não encontra ninguém:
O motivo é estrutural. Nas causas 1 e 2 o segundo res.json acontece dentro
da cadeia do Express, que envolve tudo num tratamento de erro. Aqui ele acontece
dentro de um callback que o event loop
chamou depois, longe do handler. Não existe ninguém para pegar: vira rejeição
não tratada, que no Node 24 é promovida a exceção não capturada, e o processo
encerra.
A correção tem duas partes: cancelar o relógio quando a busca chega, e conferir se alguém já respondeu.
app.get('/prontuarios/:id', (req, res) => {
const relogio = setTimeout(() => {
res.status(504).json({ erro: 'o prontuario demorou demais' });
}, 150);
buscarProntuario(req.params.id).then((prontuario) => {
clearTimeout(relogio);
if (res.headersSent) return;
res.json(prontuario);
});
});Com isso, a requisição lenta continua devolvendo 504 — e o servidor continua de pé para a próxima. Sempre que uma Promise disputa a resposta com um timer, as duas travas precisam existir.
Causa 4 — o middleware de erro respondendo por cima do handler
O VetClub tem um tratador de erro central, como toda API séria deveria ter. A rota busca a consulta pelo id e loga o pet entregue:
const agenda = [{ id: 1, pet: 'Amora', tutor: 'Marina', horario: '14:30' }];
app.get('/consultas/:id', (req, res) => {
const consulta = agenda.find((c) => c.id === Number(req.params.id));
res.json(consulta);
console.log(`consulta de ${consulta.pet} entregue`);
});
app.use((err, req, res, next) => {
console.error('[erro no handler]', err.message);
res.status(500).json({ erro: 'falha interna na clinica' });
});Pedindo um id que não existe com curl -i http://localhost:4575/consultas/99, o
cliente recebe isto:
200 com corpo vazio. O find não achou nada, res.json(undefined) é uma
resposta válida e ela saiu. Só depois a linha do console.log explodiu — é o
velho Cannot read properties of undefined —
e o middleware de erro tentou mandar um 500 que já não cabia:
A assinatura desta causa é o quadro at Layer.handleError, logo abaixo do seu
arquivo. Nas causas 1 e 2 ali estava Layer.handleRequest. handleError
significa que a segunda resposta partiu do seu middleware de erro — e que
existe um erro de verdade escondido atrás do ruído. Vale ler
tratamento de erro centralizado no Express
para montar esse handler direito.
res.headersSent: o guarda que evita o segundo envio
res.headersSent é um booleano que o Node mantém: false antes do primeiro
byte sair, true depois. É a pergunta que todo middleware de erro precisa fazer
antes de responder.
app.get('/consultas/:id', (req, res) => {
const consulta = agenda.find((c) => c.id === Number(req.params.id));
console.log('antes do res.json ->', res.headersSent);
res.json(consulta);
console.log('depois do res.json ->', res.headersSent);
console.log(`consulta de ${consulta.pet} entregue`);
});
app.use((err, req, res, next) => {
console.error('[erro]', err.message);
if (res.headersSent) {
console.error('[erro] resposta ja enviada, nao respondo de novo');
return next(err);
}
res.status(500).json({ erro: 'falha interna na clinica' });
});Compare com a saída da causa 4. O ERR_HTTP_HEADERS_SENT sumiu, e o que sobrou
no log foi o TypeError — o bug de verdade, na primeira linha e com o número
exato. Era ele que o ruído estava escondendo.
Três causas sujam o log, uma derruba o servidor
Vale saber com o que você está lidando, porque a urgência muda. Bati cinco vezes
seguidas na rota quebrada da causa 1, com o servidor rodando em segundo plano e
a saída dele redirecionada para log1.txt:
for i in 1 2 3 4 5; do
curl -s -o /dev/null -w "%{http_code} " -X POST http://localhost:4571/consultas \
-H 'Content-Type: application/json' -d '{"tutor":"Marina"}'
done
echo
pgrep -f "node causa1.js" > /dev/null && echo "servidor ainda de pe"
grep -c "ERR_HTTP_HEADERS_SENT" log1.txtCinco respostas 400 corretas, servidor vivo, e cinco blocos de stack trace acumulados no log — um por requisição. Do lado de fora, ninguém percebe. Do lado de dentro, o seu log de produção vira lixo e o alerta que importa passa despercebido no meio.
A causa 3 é de outra natureza: o processo morreu na primeira requisição, e o
curl seguinte nem conseguiu conectar. Em produção, com um orquestrador
reiniciando o container, isso vira reinício em loop.
Só que o critério não é “síncrono derruba o log, assíncrono derruba o servidor”.
O que decide é se o Express ainda tem a sua função na mão quando o erro
estoura. Troque o .then da causa 3 por await, sem mexer em mais nada:
app.get('/prontuarios/:id', async (req, res) => {
setTimeout(() => {
res.status(504).json({ erro: 'o prontuario demorou demais' });
}, 150);
const prontuario = await buscarProntuario(req.params.id);
res.json(prontuario);
});Duas requisições seguidas nessa rota, com o mesmo erro acontecendo nas duas:
node causa3-await.js > log-await.txt 2>&1 &
sleep 1
curl -s -o /dev/null -w '1a requisicao: %{http_code}\n' http://localhost:4581/prontuarios/1
sleep 0.5
curl -s -o /dev/null -w '2a requisicao: %{http_code}\n' http://localhost:4581/prontuarios/1
sleep 0.5
pgrep -f "node causa3-await.js" > /dev/null && echo "servidor ainda de pe"O processo não morre. Com await, o res.json atrasado ainda está dentro da
função async, então o erro rejeita a promise que o handler devolveu — e o
Express 5 trata essa rejeição como trataria um throw comum. No log-await.txt
fica este bloco, repetido uma vez por requisição:
Com .then, não: quando o callback roda, a função do handler já terminou e
devolveu undefined faz tempo. A rejeição nasce órfã e o processo cai. O
critério prático, então: res. depois de um await, dentro do handler
async, é log sujo e você tem tempo. res. dentro de um callback que o
Express nunca recebeu de volta — setTimeout, .then, callback de driver de
banco, listener de evento — é incidente.
Roteiro: achar o segundo res. dentro do seu handler
O stack trace mostra a segunda resposta e esconde a primeira. Este middleware
resolve isso: ele troca o res.json por uma versão que guarda de onde veio cada
envio e denuncia o segundo, sem deixar ele sair.
function deOndeVeio() {
return new Error().stack.split('\n')[3].trim();
}
app.use((req, res, next) => {
const jsonOriginal = res.json.bind(res);
let primeira = null;
res.json = (corpo) => {
if (primeira) {
console.error(`[2x] ${req.method} ${req.originalUrl}`);
console.error(` 1a resposta: ${primeira}`);
console.error(` 2a resposta: ${deOndeVeio()}`);
return res;
}
primeira = deOndeVeio();
return jsonOriginal(corpo);
};
next();
});Com ele ligado na frente da rota quebrada da causa 1 — onde, no arquivo
completo, o res.status(400) cai na linha 34 e o res.status(201) na 38:
Duas linhas, os dois culpados, com arquivo e número. É o que faltava no stack
trace original. E ele não para na sua pasta: quando a primeira resposta sai de
uma dependência, o quadro capturado vem com o caminho dentro de node_modules,
arquivo e linha da biblioteca.
O limite dele é o método. Esse rastreador cobre res.json, e só. Um middleware
que responde com res.send ou res.end passa direto pela troca e o
ERR_HTTP_HEADERS_SENT volta a estourar — se a suspeita é de terceiro, repita a
mesma troca nesses dois métodos. O roteiro de uso é curto: coloque o middleware
antes de todas as rotas, reproduza a requisição problemática, anote as duas
linhas e tire ele do projeto. Em produção, ele mascara o defeito em vez de
corrigir.
O mesmo roteiro, resumido, quando você não quiser colar código nenhum:
- Copie o quinto quadro
atdo stack trace — é o arquivo e a linha da segunda resposta. - Abra a função e conte os
res.dela. Quase sempre há dois. - Olhe o quadro logo abaixo:
Layer.handleRequestaponta para middleware ou rota;Layer.handleErroraponta para o seu tratador de erro. - Se não achar nenhum par óbvio, procure por
res.depois de umawaite dentro desetTimeout,.then,try/catche callbacks de banco.
O próximo passo
Corrigido o erro, o hábito que evita a reincidência é estrutural: uma rota
Express deveria ter um único ponto de saída por caminho, e todo res. que
não é a última linha começa com return. Quando a API cresce e um arquivo passa
a ter dez rotas, essa disciplina fica difícil de manter na base do olho — é a
hora de quebrar em
roteadores modulares por recurso e deixar
cada arquivo pequeno o bastante para caber na tela. A
trilha de Node segue daí para validação de entrada e
autenticação, onde middlewares que respondem cedo são justamente a regra.
Como teste final, faça duas chamadas à rota corrigida: uma sem pet, que deve
responder uma única vez com 400, e outra válida, que deve responder uma única
vez com 201. Conte um log imediatamente antes de cada res.. Se uma chamada
produzir dois logs de envio ou aparecer ERR_HTTP_HEADERS_SENT, ainda existe um
caminho sem return ou um callback respondendo tarde.
Prefere aprender em vídeo?
Tem uma aula sobre este assunto no nosso canal.
Perguntas frequentes
Esse erro é do Express ou do Node?
Posso enviar o corpo em pedaços sem cair nesse erro?
E se a segunda resposta vier de uma biblioteca de terceiros?
Dá para envolver o res.json num try/catch e seguir a vida?
Por que o cliente recebeu 200 com corpo vazio em vez do erro?
Dúvidas e comentários
Travou em algum passo? Pergunte aqui — a equipe e outros alunos respondem.
Entrar para perguntarÉ o mesmo login gratuito dos cursos.
Nenhuma dúvida por aqui ainda — a primeira pode ser a sua.
Todo o código deste artigo foi executado em Node 24.16.0 com Express 5.2.1, e as saídas exibidas são as reais — como produzimos este conteúdo.
Fontes consultadas
- Node.js — ERR_HTTP_HEADERS_SENT — nodejs.org
- Express 5 — res.headersSent — expressjs.com
- MDN — Mensagens HTTP — developer.mozilla.org



