Debugging Mental: Como Investigar Bugs em JavaScript Sem Depender do Console.log

Conteúdo técnico toda semana
Receba artigos sobre arquitetura, padrões de projeto e engenharia de software. Direto no seu e-mail, sem enrolação.
Sem spam. Cancele a qualquer momento com 1 clique.
Neste artigo
Um console.log resolve bugs simples. O problema é que bugs simples não são os que consomem horas do seu dia. Quando o estado muda entre dois renders, quando uma promise resolve com valor inesperado, quando o payload chega correto na API mas o objeto no handler já está diferente: nesses cenários, console.log vira um ciclo de adicionar, salvar, recarregar, ler, adicionar mais um, salvar de novo. Esse ciclo tem nome: printf debugging. E ele escala mal.
Este post cobre as ferramentas que já existem no seu ambiente (DevTools, debugger statement, Node.js inspector) e técnicas de raciocínio que reduzem o tempo entre "tem um bug" e "achei a causa raiz".
O custo real do printf debugging
O fluxo típico de quem depende de console.log:
- Suspeita de onde o bug está.
- Adiciona logs em 3-5 pontos.
- Reproduz o bug.
- Lê o output, percebe que precisa de mais contexto.
- Adiciona mais logs.
- Repete até encontrar (ou até desistir e reescrever o trecho).
Cada iteração desse ciclo custa entre 30 segundos e 2 minutos. Em bugs de estado ou de concorrência assíncrona, são 10-15 iterações. Faça a conta: 15 a 30 minutos gastos em instrumentação, não em investigação.
Breakpoints eliminam esse ciclo porque você inspeciona qualquer variável no escopo sem modificar código. A diferença não é filosófica. É operacional.
Breakpoints condicionais: o substituto direto
O Chrome DevTools (e qualquer navegador baseado em Chromium) aceita breakpoints com condição. Você clica com botão direito na linha, escolhe "Add conditional breakpoint" e escreve uma expressão JavaScript. A execução só pausa quando a expressão retorna truthy.
// Cenário: uma lista de pedidos renderiza com preço errado.
// Você suspeita que o problema está na função de cálculo.
function calcularTotal(pedido) {
const subtotal = pedido.itens.reduce(
(acc, item) => acc + item.preco * item.quantidade,
0
);
// Breakpoint condicional aqui: pedido.id === 'ped-4821'
// Isso pausa APENAS no pedido problemático, ignorando os outros 200.
const desconto = pedido.cupom ? aplicarCupom(pedido.cupom, subtotal) : 0;
return subtotal - desconto;
}No painel de Sources do DevTools, ao pausar, você tem acesso ao Scope completo: variáveis locais, closure, módulo. Pode avaliar expressões no Console enquanto pausado. Pode modificar valores em tempo real para testar hipóteses.
Logpoints: console.log sem tocar no código
Logpoints são a ponte para quem não quer largar o hábito de ler logs mas quer parar de editar arquivos. No Chrome DevTools, clique com botão direito na linha e escolha "Add logpoint". A sintaxe aceita interpolação:
// Logpoint configurado no DevTools (não é código no arquivo):
// "calcularTotal chamado: pedido.id={pedido.id}, subtotal={subtotal}"
// Isso imprime no Console sem pausar a execução,
// sem modificar o arquivo fonte,
// sem precisar de rebuild.A vantagem sobre console.log é que logpoints não poluem o código, não precisam de commit acidental, e você remove fechando a aba. Para projetos com hot reload lento ou builds pesados, a economia de tempo é significativa.
O debugger statement: quando você quer controle no código
Existe um caso legítimo para inserir algo no código: quando o bug só reproduz em um ambiente específico e você precisa que qualquer pessoa do time consiga pausar ali.
async function processarWebhook(req, res) {
const evento = req.body;
// Pausa aqui quando o Node.js inspector está conectado.
// Se nenhum debugger está conectado, o runtime ignora este statement.
if (evento.tipo === "pagamento.falhou") {
debugger;
}
const resultado = await tratarEvento(evento);
res.status(200).json(resultado);
}O debugger statement funciona tanto no navegador quanto no Node.js (com inspector ativo). A diferença de comportamento entre os dois runtimes é que no Node.js você precisa iniciar o processo com a flag --inspect ou --inspect-brk.
Node.js Inspector: debugging no backend
Para debugar código Node.js sem console.log, conecte o Chrome DevTools ao processo:
# Inicia o processo com inspector na porta padrão 9229
node --inspect src/server.js
# Para pausar na primeira linha (útil para bugs de inicialização)
node --inspect-brk src/server.jsDepois, abra chrome://inspect no Chrome e clique em "inspect" no target que aparece. Você ganha o mesmo painel de Sources, breakpoints condicionais e watch expressions que tem no frontend.
Para projetos com Fastify e Prisma ou qualquer framework backend, o fluxo é o mesmo. O inspector conecta no runtime V8, não no framework.
// package.json - scripts para facilitar debugging
{
"scripts": {
"dev": "tsx watch src/server.ts",
"debug": "node --inspect -r tsx/cjs src/server.ts",
"debug:brk": "node --inspect-brk -r tsx/cjs src/server.ts"
}
}VS Code: debugging integrado sem sair do editor
O VS Code tem um debugger embutido que conecta no Node.js inspector e no Chrome. A configuração vive em .vscode/launch.json:
// .vscode/launch.json
{
"version": "0.2.0",
"configurations": [
{
"name": "Debug Server",
"type": "node",
"request": "launch",
"program": "${workspaceFolder}/src/server.ts",
// runtimeArgs registra tsx como loader para TypeScript
"runtimeArgs": ["-r", "tsx/cjs"],
"restart": true,
"console": "integratedTerminal",
// skipFiles evita que o debugger pause dentro de node_modules
"skipFiles": ["<node_internals>/**", "node_modules/**"]
},
{
"name": "Debug Testes",
"type": "node",
"request": "launch",
"program": "${workspaceFolder}/node_modules/.bin/vitest",
"args": ["run", "--reporter=verbose"],
"console": "integratedTerminal",
"skipFiles": ["<node_internals>/**"]
}
]
}Com essa configuração, você coloca breakpoints clicando na margem do editor, aperta F5 e o VS Code pausa exatamente onde você pediu. Watch expressions, call stack, variáveis locais: tudo no painel lateral.
Essa abordagem é especialmente útil quando você trabalha com Clean Architecture e TypeScript, onde o bug pode estar em qualquer camada e navegar pela call stack mostra exatamente o caminho que a execução percorreu.
Source maps: debugando TypeScript como se fosse o código real
Se você usa TypeScript, o debugger mostra o código compilado por padrão. Source maps resolvem isso mapeando o .js gerado de volta para o .ts original.
// tsconfig.json - o mínimo para debugging funcionar
{
"compilerOptions": {
"sourceMap": true,
// inlineSourceMap é alternativa: embute o map no próprio .js
// Use sourceMap (arquivo separado) em produção para não aumentar o bundle
"outDir": "./dist",
"rootDir": "./src"
}
}Quando sourceMap está true, o compilador gera arquivos .js.map junto com cada .js. O debugger (DevTools ou VS Code) lê esses maps e mostra o TypeScript original, com breakpoints funcionando nas linhas corretas.
Se você usa bundlers como esbuild ou Vite, verifique que o source map está habilitado no modo de desenvolvimento. No Vite, build.sourcemap controla isso. No esbuild, a flag --sourcemap resolve.
Tabela comparativa: técnicas de debugging
| Técnica | Modifica código? | Funciona no Node.js? | Funciona no browser? | Persiste entre sessões? | Melhor para |
|---|---|---|---|---|---|
console.log | Sim | Sim | Sim | Sim (até remover) | Bugs triviais, logging permanente |
| Breakpoint no DevTools | Não | Sim (via inspector) | Sim | Não (perde ao fechar) | Investigação exploratória |
| Breakpoint condicional | Não | Sim | Sim | Não | Bugs que só ocorrem com dados específicos |
| Logpoint | Não | Sim (VS Code) | Sim | Não | Quem quer logs sem editar arquivo |
debugger statement | Sim | Sim | Sim | Sim | Bugs compartilhados no time |
| VS Code launch config | Não | Sim | Via extensão | Sim (.vscode/) | Projetos com setup complexo |
O que NÃO fazer
Anti-pattern 1: console.log de objetos mutáveis
// ERRADO: o Console do Chrome avalia objetos lazily.
// Quando você expande o objeto no Console, ele mostra o estado ATUAL,
// não o estado no momento do log.
const carrinho = { itens: [], total: 0 };
console.log("Estado do carrinho:", carrinho);
// ... 50 linhas depois, carrinho.itens é modificado ...
carrinho.itens.push({ nome: "Teclado", preco: 350 });
carrinho.total = 350;
// Quando você expande o log no Console, vê { itens: [{ nome: "Teclado" }], total: 350 }
// Mas no momento do log, itens era [] e total era 0.// CORRETO: serialize o objeto para capturar o estado naquele instante.
// structuredClone cria uma cópia profunda sem referências compartilhadas.
const carrinho = { itens: [], total: 0 };
console.log("Estado do carrinho:", structuredClone(carrinho));
// Alternativa que funciona para objetos serializáveis:
console.log("Estado do carrinho:", JSON.parse(JSON.stringify(carrinho)));Esse bug de avaliação lazy já consumiu horas de debugging de muita gente. O objeto parece correto no Console porque o Console mostra o valor atual, não o valor no momento da chamada. Se você insiste em usar console.log para objetos, structuredClone é obrigatório.
Anti-pattern 2: logs espalhados sem estratégia de remoção
// ERRADO: logs de debugging que vazam para produção.
// Sem convenção, ninguém sabe quais logs são temporários.
export async function buscarUsuario(id) {
console.log(">>> buscarUsuario chamado com:", id); // debugging?
console.log("timestamp:", Date.now()); // debugging?
const usuario = await db.usuario.findUnique({ where: { id } });
console.log("resultado:", usuario); // debugging?
return usuario;
}// CORRETO: se precisar de logs temporários, use um padrão grep-ável.
// Melhor ainda: use breakpoints e não adicione nada.
const DEBUG_PREFIX = "[DEBUG:buscarUsuario]"; // grep -r "DEBUG:" remove tudo
export async function buscarUsuario(id) {
// Para logging permanente, use um logger estruturado (pino, winston)
// com níveis (debug, info, warn, error) e desabilite debug em produção.
logger.debug({ userId: id }, "Buscando usuário");
const usuario = await db.usuario.findUnique({ where: { id } });
return usuario;
}Para projetos backend como microserviços com Node.js, logging estruturado com Pino ou Winston não é opcional. A diferença entre console.log e um logger estruturado é que o logger produz JSON parseável, aceita níveis configuráveis por ambiente e integra com ferramentas de observabilidade.
Anti-pattern 3: ignorar a call stack
// ERRADO: adicionar console.log para descobrir "quem chamou essa função"
function atualizarEstoque(produtoId, quantidade) {
console.log("atualizarEstoque chamado!"); // mas quem chamou?
console.log("chamador:", new Error().stack); // hack feio
// ...
}// CORRETO: use console.trace() se precisa de stack no console,
// ou coloque um breakpoint e leia a Call Stack no DevTools.
function atualizarEstoque(produtoId, quantidade) {
console.trace("atualizarEstoque"); // imprime stack trace completo
// Mas a solução real: breakpoint aqui e olhe o painel Call Stack.
// Você vê cada frame, pode clicar e navegar para o chamador.
}O painel Call Stack do DevTools mostra a cadeia completa de chamadas. Você clica em qualquer frame e o editor pula para aquela linha, com todas as variáveis do escopo daquele frame disponíveis. Isso é ordens de magnitude mais rápido que construir new Error().stack manualmente.
Debugging de código assíncrono: async stack traces
Promises e async/await historicamente quebravam a call stack: quando a promise resolvia, o stack trace mostrava apenas o microtask handler, não quem originou a chamada. O V8 resolveu isso com async stack traces, habilitados por padrão no Chrome DevTools e no Node.js inspector.
// Com async stack traces habilitado, o DevTools mostra
// a cadeia completa mesmo através de awaits.
async function handleRequest(req) {
const usuario = await autenticar(req.headers.authorization);
const pedidos = await buscarPedidos(usuario.id);
// Se buscarPedidos lança erro, o stack trace mostra:
// 1. buscarPedidos (onde o erro ocorreu)
// 2. handleRequest (quem chamou)
// 3. O handler HTTP que invocou handleRequest
return pedidos;
}
async function buscarPedidos(usuarioId) {
const resultado = await db.pedido.findMany({
where: { usuarioId },
include: { itens: true },
});
// Breakpoint aqui: o painel Call Stack mostra toda a cadeia async
return resultado;
}Se você trabalha com comunicação em tempo real ou Socket.IO, async stack traces são a diferença entre saber que "algo falhou no handler" e saber exatamente qual evento, de qual cliente, disparou a cadeia que falhou.
Técnica de investigação: bisect mental
Antes de abrir qualquer ferramenta, aplique bisect mental. O raciocínio é o mesmo do git bisect: divida o caminho que os dados percorrem pela metade e verifique se o estado está correto naquele ponto.
- Identifique a entrada (request, evento, prop) e a saída esperada.
- Coloque UM breakpoint no meio do caminho.
- Se o estado está correto ali, o bug está na segunda metade. Se está errado, está na primeira.
- Repita, dividindo a metade problemática.
Em 3-4 iterações, você isola o bug em uma função específica. Isso funciona para qualquer arquitetura, seja um handler HTTP direto, seja uma cadeia de Server Components e Client Components no Next.js, seja um pipeline de middleware na edge.
Network tab: o debugger que ninguém usa direito
Metade dos bugs de frontend não são bugs de lógica. São bugs de dados: a API retornou algo diferente do esperado. A aba Network do DevTools mostra request e response completos, com headers, timing e payload.
// Cenário: o componente mostra "undefined" onde deveria mostrar o nome do usuário.
// Antes de debugar o componente, verifique na Network tab:
// 1. A request foi enviada? (pode ser um problema de timing ou condição)
// 2. O response tem o campo esperado? (a API pode ter mudado o schema)
// 3. O status é 200? (pode ser 304 com cache stale)
// Se o response está correto, o bug é no frontend.
// Se o response está errado, o bug é no backend.
// Essa triagem leva 10 segundos e evita debugar o lado errado.Para quem trabalha com busca semântica ou qualquer integração com APIs externas, a Network tab é o primeiro lugar para olhar, não o último.
Performance tab para bugs de "lentidão"
Nem todo bug é um erro. Às vezes o código funciona, mas demora. A Performance tab do DevTools grava um flamegraph da execução e mostra exatamente quais funções consumiram mais tempo.
# No Node.js, você pode gerar um profile similar:
node --prof src/server.js
# Depois processe o log gerado:
node --prof-process isolate-0x*.log > profile.txtO flamegraph mostra se o gargalo é CPU (função pesada, loop ineficiente) ou I/O (esperando resposta de banco, API externa). Essa distinção muda completamente a estratégia de correção: gargalo de CPU pede otimização de algoritmo; gargalo de I/O pede paralelização ou cache. Quem trabalha com Edge Functions vs Serverless sabe que o ambiente de execução muda o perfil de performance de forma drástica.
FAQ
Breakpoints funcionam com hot reload (HMR)?
Sim, mas com ressalvas. Quando o HMR atualiza um módulo, breakpoints colocados no DevTools podem perder a referência à linha original. Breakpoints colocados no VS Code (via launch config) tendem a sobreviver melhor porque o VS Code re-mapeia usando source maps. Se o breakpoint some após um hot reload, recarregue a página (frontend) ou reinicie o debugger (backend).
Posso debugar código em produção?
Não com breakpoints (você pausaria o processo para todos os usuários). Para produção, use logging estruturado com níveis (Pino com level: 'info'), tracing distribuído (OpenTelemetry) e error tracking (Sentry, Datadog). O debugging interativo é ferramenta de desenvolvimento, não de operação.
O debugger statement tem impacto em performance na produção?
O impacto é negligível. Quando nenhum inspector está conectado, o V8 trata debugger como um no-op. Mesmo assim, não deixe debugger statements no código de produção: linters como ESLint têm a regra no-debugger habilitada por padrão, e ela existe por bom motivo. Um debugger esquecido em produção com inspector acidentalmente aberto pausa o processo inteiro.
Como debugar testes unitários?
A configuração de launch.json mostrada acima funciona para Vitest. Para Jest, troque o program para node_modules/.bin/jest e adicione "args": ["--runInBand"]. O --runInBand é necessário porque Jest roda testes em workers separados por padrão, e o debugger não conecta nos workers automaticamente.
Source maps funcionam com minificação?
Sim, essa é exatamente a razão pela qual source maps existem. O bundler gera o arquivo minificado e um .map que mapeia cada posição no código minificado de volta para o código original. Em produção, você pode gerar source maps como arquivos separados (não inline) e enviá-los apenas para o serviço de error tracking, sem expô-los ao usuário final.
A posição que defendo
console.log não é debugging. É instrumentação manual descartável. A diferença entre um dev que resolve bugs em 5 minutos e um que gasta 2 horas é, na maioria das vezes, domínio de ferramentas que já estão instaladas. O Chrome DevTools, o Node.js inspector e o debugger do VS Code não exigem instalação extra, não exigem configuração complexa e fazem mais do que qualquer combinação de console.log, console.table e console.dir.
Invista 30 minutos configurando o launch.json do seu projeto. Na próxima vez que um bug aparecer, coloque um breakpoint antes de digitar console.log. Em duas semanas, o hábito muda. E o tempo que você economiza compõe.

Escrito por
Marcos Soares
Fullstack Developer · CEO da Agência Poti
Fullstack Developer e CEO da Agência Poti. Mais de 20 anos construindo arquiteturas cloud-native com React, Next.js e sistemas distribuídos. Parceiro comercial do estúdio iellou design. Fundador do Vivo de Código.
Comentários
Participe da discussão
Seja o primeiro a comentar!
Continue Aprofundando

5 Lacunas Críticas que Todo Dev JavaScript Ignora (e Como Cobrir Cada Uma)

Como Criar uma CLI Profissional com Node.js e TypeScript

CLI com IA: Construindo Ferramentas de Linha de Comando em Python e JavaScript que Usam LLMs de Verdade
Conteúdo técnico toda semana
Receba artigos sobre arquitetura, padrões de projeto e engenharia de software. Direto no seu e-mail, sem enrolação.
Sem spam. Cancele a qualquer momento com 1 clique.