Tratamento de erro centralizado no Express, num handler só
Como capturar o erro de todas as rotas num único middleware de quatro argumentos, padronizar a resposta JSON e não vazar stack trace em produção.
Um handler de erro no Express é um app.use com quatro argumentos,
declarado depois de todas as rotas. Quando uma rota chama next(erro) — ou, no
Express 5, simplesmente lança —, a requisição pula todo o resto e cai nele. É
ali, num lugar só, que você escolhe o status, o corpo do JSON e o que vai para o
log.
Os exemplos deste artigo são a API de uma clínica veterinária: pets, tutores e agenda de consultas. Tudo foi executado no Node 24.16.0, com Express 4.21.2 e Express 5.1.0 instalados lado a lado, porque a diferença entre as duas versões é o ponto que mais confunde quem chega agora.
Uma central de ocorrências para a API inteira
Imagine um prédio em que cada sala inventa um procedimento quando falta luz: uma liga para a portaria, outra abre a janela, outra simplesmente fica no escuro. Um handler centralizado é a central de ocorrências: as salas continuam detectando o problema, mas encaminham a mesma ficha para um único lugar decidir resposta, registro e prioridade.
No Express, essa ficha é um objeto de erro e o encaminhamento acontece com
next(erro) — ou com um throw assíncrono no Express 5. O nome técnico é
middleware de tratamento de erro, reconhecido pelos quatro parâmetros. Ele
não corrige o defeito e não elimina validação na rota; só garante uma saída
consistente. Ao ler os exemplos, faça uma pergunta por erro: “quem percebeu?” e
“quem decidiu o status?”. Se a mesma função faz as duas coisas em toda rota,
você encontrou a repetição que esta lição vai remover.
O try/catch em toda rota é o sintoma, não a solução
Este é o código que quase toda API começa tendo. Cada rota se defende sozinha:
app.get('/pets/:id', (req, res) => {
try {
const pet = pets.find((p) => p.id === Number(req.params.id));
if (!pet) {
return res.status(404).json({ erro: 'Pet nao encontrado' });
}
res.json(pet);
} catch (e) {
res.status(500).json({ erro: e.message });
}
});
app.post('/consultas', (req, res) => {
try {
const { petId, data } = req.body;
if (!petId) {
return res.status(400).json({ mensagem: 'petId e obrigatorio', ok: false });
}
res.status(201).json({ id: 77, petId, data });
} catch (e) {
res.status(500).send('Erro interno');
}
});Funciona. O problema aparece quando o front tenta ler as duas respostas:
curl -s http://localhost:3901/pets/99
curl -s -X POST http://localhost:3901/consultas -H 'Content-Type: application/json' -d '{}'Duas rotas do mesmo servidor, dois formatos de erro diferentes. Uma tem erro,
a outra tem mensagem e um ok que ninguém pediu. A terceira rota vai inventar
o terceiro formato. E o catch genérico devolve e.message cru para o cliente,
que é como um nome de coluna do banco acaba aparecendo na tela do usuário.
O try/catch não é o vilão — ele continua sendo a ferramenta certa para
tratar erro dentro de uma função. O que está
errado é cada rota decidir sozinha como um erro vira resposta HTTP.
O handler tem quatro argumentos, e vem por último
A rota volta a ser só a regra de negócio. Quando algo dá errado, ela chama
next(erro) e sai de cena:
app.get('/pets/:id', (req, res, next) => {
const pet = pets.find((p) => p.id === Number(req.params.id));
if (!pet) {
const erro = new Error('Pet nao encontrado');
erro.status = 404;
return next(erro);
}
res.json(pet);
});
app.get('/pets/:id/vacinas', (req, res) => {
// erro NÃO provocado de propósito: o pet não existe e ninguém checou
const pet = pets.find((p) => p.id === Number(req.params.id));
res.json(pet.vacinas.map((v) => v.nome));
});
// quatro argumentos, depois de todas as rotas
app.use((erro, req, res, next) => {
console.error(`[erro] ${req.method} ${req.originalUrl} -> ${erro.message}`);
res.status(erro.status ?? 500).json({ erro: erro.message });
});A segunda rota não tem try/catch nenhum e mesmo assim está protegida: o
Express embrulha a chamada de cada handler síncrono num try/catch interno e
manda o que for lançado para a fila de erro.
curl -s http://localhost:3102/pets/99
curl -s http://localhost:3102/pets/99/vacinasE, no terminal do servidor, as duas passagens pelo mesmo lugar:
Repare que o 404 previsto e o TypeError acidental chegaram no mesmo ponto.
Isso é bom e é perigoso ao mesmo tempo: a mensagem do bug foi parar na resposta.
Daqui a pouco a gente separa os dois.
Três argumentos em vez de quatro: o erro que todo mundo comete
O Express identifica um handler de erro pela aridade da função, ou seja, pelo número de parâmetros declarados. Com três, ele vira middleware comum e nunca recebe o erro:
// FALTOU O QUARTO ARGUMENTO
app.use((erro, req, res) => {
res.status(500).json({ erro: erro.message });
});O sintoma não é uma exceção: é a resposta ficar em HTML, porque quem respondeu foi o handler padrão do próprio Express.
<!DOCTYPE html> <html lang=“en”> <head> <meta charset=“utf-8”> <title>Error</title> </head> <body> <pre>TypeError: Cannot read properties of undefined (reading 'vacinas')<br> at file:///private/tmp/clinica/e5/03-tres-args.js:7:16<br> at Layer.handleRequest (/private/tmp/clinica/e5/node_modules/router/lib/layer.js:152:17)<br> …
Sempre que a sua API devolver Content-Type: text/html num erro, o diagnóstico é
esse: o erro não encontrou nenhum handler seu e caiu no do Express. Conte os
argumentos. E note o que vai junto: o caminho absoluto do arquivo no servidor e a
árvore inteira de node_modules.
Erro dentro de await: o que muda do Express 4 para o 5
Aqui está a diferença que quebra projeto real. A rota abaixo é idêntica nas duas versões — o mesmo arquivo, copiado para duas pastas com Express diferentes:
async function buscarNoBanco(id) {
await new Promise((r) => setTimeout(r, 10));
throw new Error('conexao com o banco recusada');
}
app.get('/consultas/:id', async (req, res) => {
const consulta = await buscarNoBanco(req.params.id);
res.json(consulta);
});
app.use((erro, req, res, next) => {
console.error(`[erro] ${req.method} ${req.originalUrl} -> ${erro.message}`);
res.status(500).json({ erro: 'Falha ao buscar a consulta' });
});No Express 4.21.2, o handler nunca é chamado. A promise rejeitada não tem dono, e no Node moderno isso derruba o processo inteiro:
curl -i --max-time 4 http://localhost:3204/consultas/12E o terminal do servidor:
Error: conexao com o banco recusada at buscarNoBanco (file:///private/tmp/clinica/e4/04-async.js:7:9) at async file:///private/tmp/clinica/e4/04-async.js:11:20
Node.js v24.16.0
O cliente recebeu resposta vazia porque não sobrou servidor para responder. Em
Node antigo o estrago era outro e mais silencioso: a requisição ficava pendurada
até o timeout do cliente e o processo seguia vivo. Do Node 15 em diante, promise
rejeitada sem catch vira exceção não tratada e mata o processo — a mesma falha,
agora barulhenta.
No Express 5.1.0, mesmo arquivo, mesmo Node:
{“erro”:“Falha ao buscar a consulta”}
O servidor continuou de pé e a segunda requisição também respondeu 500. Essa é a
mudança mais importante do Express 5 para quem escreve API: a rota async que
rejeita passou a cair no handler de erro sozinha.
| a rota faz | Express 4.21.2 | Express 5.1.0 |
|---|---|---|
throw síncrono |
vai para o handler | vai para o handler |
next(erro) |
vai para o handler | vai para o handler |
throw depois de await |
promise rejeitada sem dono; processo cai | vai para o handler |
Promise.reject() retornada |
promise rejeitada sem dono; processo cai | vai para o handler |
Se você está preso no Express 4, a correção é embrulhar cada handler async
numa função que liga o .catch no next:
const rota = (handler) => (req, res, next) =>
Promise.resolve(handler(req, res, next)).catch(next);
app.get(
'/consultas/:id',
rota(async (req, res) => {
const consulta = await buscarNoBanco(req.params.id);
res.json(consulta);
}),
);Mesmo comportamento do Express 5, em cinco linhas. O preço é lembrar de escrever
rota(...) em todo handler assíncrono — esquecer em um só devolve o
crash silencioso de novo. Se async e await ainda são novidade para você, vale
revisar como o await propaga a rejeição antes de
seguir.
Uma classe de erro com status, código e mensagem pública
Pendurar erro.status na mão funciona, mas não diz nada sobre a intenção. O
que o handler precisa saber é outra coisa: este erro foi previsto por mim, e
portanto a mensagem pode ir para o cliente, ou é um bug, e a mensagem precisa
ficar no log?
export class ErroDaApi extends Error {
constructor({ status, codigo, mensagem }) {
super(mensagem);
this.name = 'ErroDaApi';
this.status = status;
this.codigo = codigo;
}
}
app.get('/pets/:id', (req, res, next) => {
const pet = pets.find((p) => p.id === Number(req.params.id));
if (!pet) {
return next(
new ErroDaApi({
status: 404,
codigo: 'PET_NAO_ENCONTRADO',
mensagem: `Nao existe pet com id ${req.params.id}`,
}),
);
}
res.json(pet);
});
app.post('/consultas', (req, res, next) => {
const { petId, horario } = req.body;
if (agenda.has(horario)) {
return next(
new ErroDaApi({
status: 409,
codigo: 'HORARIO_OCUPADO',
mensagem: `O horario ${horario} ja esta reservado`,
}),
);
}
agenda.add(horario);
res.status(201).json({ id: 77, petId, horario });
});O codigo é o campo que o front realmente usa. status é HTTP e é grosso
demais: 409 pode ser horário ocupado, pet duplicado ou consulta já cancelada.
HORARIO_OCUPADO é específico e estável — dá para o front abrir o seletor de
horários sem ler texto. Se você tem dúvida sobre qual número usar em cada caso,
a lição de métodos HTTP e status code
tem a tabela completa.
A resposta padronizada, e o bug que não pode vazar
Agora o handler distingue os dois mundos:
app.use((erro, req, res, next) => {
const previsto = erro instanceof ErroDaApi;
const status = previsto ? erro.status : 500;
if (!previsto) {
console.error(`[500] ${req.method} ${req.originalUrl}`, erro);
}
const corpo = {
erro: {
codigo: previsto ? erro.codigo : 'ERRO_INTERNO',
mensagem: previsto ? erro.message : 'Erro interno na clinica',
rota: `${req.method} ${req.originalUrl}`,
},
};
if (process.env.NODE_ENV !== 'production') {
corpo.erro.stack = erro.stack.split('\n').slice(0, 3);
}
res.status(status).json(corpo);
});Com NODE_ENV=production, quatro requisições diferentes devolvem o mesmo
envelope:
curl -s -w " [status %{http_code}]\n" http://localhost:3107/pets/42
curl -s -w " [status %{http_code}]\n" -X POST http://localhost:3107/consultas \
-H 'Content-Type: application/json' -d '{"petId":1,"horario":"2026-09-02T09:00"}'
curl -s -w " [status %{http_code}]\n" http://localhost:3107/gatos
curl -s -w " [status %{http_code}]\n" http://localhost:3107/relatorios/faturamentoA quarta rota tem um bug de verdade: ela chama .query() em cima de null. O
cliente recebeu “Erro interno na clinica” e nada mais. O detalhe ficou onde
deveria — no log do servidor:
404 não é erro: é o handler que vem logo antes
A terceira resposta acima, ROTA_INEXISTENTE, não veio de rota nenhuma. Ela vem
de um app.use sem caminho, registrado depois das rotas e antes do
handler de erro:
app.use((req, res, next) => {
next(
new ErroDaApi({
status: 404,
codigo: 'ROTA_INEXISTENTE',
mensagem: `A rota ${req.method} ${req.originalUrl} nao existe nesta API`,
}),
);
});A lógica é simples: se a requisição chegou até aqui, nenhuma rota bateu. Esse
middleware transforma “não achei” num ErroDaApi e entrega para o mesmo handler,
para que a resposta de rota inexistente tenha o mesmo formato de todas as outras.
Erro de terceiro também cai aqui, com status próprio
Um detalhe que só aparece em produção: o express.json() também lança. Mande um
corpo com JSON quebrado para a API acima:
curl -s -w " [status %{http_code}]\n" -X POST http://localhost:3107/consultas \
-H 'Content-Type: application/json' -d '{"petId":1,'[500] POST /consultas SyntaxError: Expected double-quoted property name in JSON at position 11 (line 1 column 12) at JSON.parse (<anonymous>)
Culpa do cliente, mas a API assumiu como se fosse dela: 500 e log de erro
interno. O SyntaxError do body-parser já vem com status: 400, e basta o
handler respeitar isso:
const previsto = erro instanceof ErroDaApi;
const statusDeTerceiro =
Number.isInteger(erro.status) && erro.status < 500 ? erro.status : null;
const status = previsto ? erro.status : (statusDeTerceiro ?? 500);
if (!previsto && !statusDeTerceiro) {
console.error(`[500] ${req.method} ${req.originalUrl}`, erro);
}
const corpo = {
erro: {
codigo: previsto ? erro.codigo : statusDeTerceiro ? 'CORPO_INVALIDO' : 'ERRO_INTERNO',
mensagem: previsto || statusDeTerceiro ? erro.message : 'Erro interno na clinica',
rota: `${req.method} ${req.originalUrl}`,
},
};Agora o JSON quebrado dá 400 e não polui o log, enquanto o bug de verdade
continua 500. O critério status < 500 é de propósito: erro de terceiro que se
declara 5xx é problema seu, não do cliente. Para o resto da validação de corpo,
o caminho é validar a entrada com um schema
antes de a rota rodar.
Stack em desenvolvimento, mensagem seca em produção
A única diferença entre os dois ambientes é aquele if do NODE_ENV. Com
NODE_ENV=development, a mesma rota quebrada devolve as três primeiras linhas
do stack dentro do JSON:
NODE_ENV=development node 06-api.js
curl -s http://localhost:3107/relatorios/faturamento | json_ppEm produção o campo stack some. Não é frescura de segurança: o stack entrega o
caminho absoluto do arquivo no servidor, a estrutura de pastas do projeto e as
versões das bibliotecas — três informações que ajudam quem está procurando
brecha. O que o cliente precisa é do codigo; o que você precisa está no log.
O que o handler não pega
O handler de erro só enxerga o que acontece durante o ciclo de uma requisição. Fora dele, ele não existe. A rota abaixo responde e só depois quebra:
app.get('/lembretes/:id', (req, res) => {
res.json({ agendado: true });
setTimeout(() => {
throw new Error('falha ao enviar o lembrete de vacina');
}, 50);
});{“agendado”:true}
Cinquenta milissegundos depois, o terminal do servidor:
Error: falha ao enviar o lembrete de vacina at Timeout._onTimeout (file:///private/tmp/clinica/e5/07-fora.js:8:11) at listOnTimeout (node:internal/timers:605:17) at process.processTimers (node:internal/timers:541:7)
Node.js v24.16.0
O cliente recebeu 200 e foi embora feliz, e o handler nem foi consultado —
quando o setTimeout disparou, a requisição já tinha terminado e o Express não
tinha mais nada a ver com aquela pilha. Para esse caso existem os eventos do
processo:
const servidor = app.listen(3109, () => console.log('clinica ouvindo na 3109'));
process.on('uncaughtException', (erro) => {
console.error('[fatal] uncaughtException:', erro.message);
servidor.close(() => process.exit(1));
setTimeout(() => process.exit(1), 5000).unref();
});
process.on('unhandledRejection', (motivo) => {
console.error('[fatal] unhandledRejection:', motivo);
servidor.close(() => process.exit(1));
setTimeout(() => process.exit(1), 5000).unref();
});Rodando e batendo na mesma rota, com o shell mostrando o código de saída:
node 08-fatal.js; echo "[node saiu com codigo $?]"Repare no que esse código não faz: ele não continua rodando. Depois de um
uncaughtException, o processo está em estado desconhecido — pode haver conexão
de banco pela metade e variável corrompida. O papel desses dois listeners é
registrar o motivo, parar de aceitar requisição nova, deixar as em andamento
terminarem e sair com código 1, para o orquestrador subir um processo limpo.
servidor.close() sozinho pode nunca chamar o callback se houver conexão presa;
por isso o setTimeout com unref() como prazo final.
O que fazer agora
Abra a sua API e faça três coisas, nesta ordem: confira se o app.use de erro
tem os quatro parâmetros, rode npm ls express para saber em qual versão você
está, e apague o primeiro try/catch de rota que encontrar, deixando o erro
subir. Depois disso, a próxima peça é
validar a entrada antes de a rota rodar — com
o handler pronto, a validação vira só mais um ErroDaApi de 400. Para ver onde
esta lição entra no percurso completo, o
guia de Node.js mostra a ordem inteira, e a
trilha de Node lista as lições na sequência.
Perguntas frequentes
Posso ter mais de um handler de erro no mesmo app?
Validação que falha devolve 400 ou 422?
express-async-errors ainda faz sentido?
Como devolvo o mesmo erro em várias linguagens?
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, Express 4.21.2 e Express 5.1.0, e as saídas exibidas são as reais — como produzimos este conteúdo.
Fontes consultadas
- Express — Error Handling — expressjs.com
- Express 5 — Migrating from 4.x: rejected promises — expressjs.com
- MDN — HTTP response status codes — developer.mozilla.org


