Ir para o conteúdo
Backend

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

Marcos Soares
Atualizado em 
14 minutos de leitura
Ilustração 3D de lupa de vidro fosco sobre bloco de código fraturado com luz esmeralda representando debugging JavaScript
Ouça este artigo
0:00Debugging 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:

  1. Suspeita de onde o bug está.
  2. Adiciona logs em 3-5 pontos.
  3. Reproduz o bug.
  4. Lê o output, percebe que precisa de mais contexto.
  5. Adiciona mais logs.
  6. 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.

JavaScript
// 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:

JavaScript
// 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.

JavaScript
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:

Bash
# 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.js

Depois, 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.

JSON
// 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:

JSONC
// .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.

JSONC
// 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écnicaModifica código?Funciona no Node.js?Funciona no browser?Persiste entre sessões?Melhor para
console.logSimSimSimSim (até remover)Bugs triviais, logging permanente
Breakpoint no DevToolsNãoSim (via inspector)SimNão (perde ao fechar)Investigação exploratória
Breakpoint condicionalNãoSimSimNãoBugs que só ocorrem com dados específicos
LogpointNãoSim (VS Code)SimNãoQuem quer logs sem editar arquivo
debugger statementSimSimSimSimBugs compartilhados no time
VS Code launch configNãoSimVia extensãoSim (.vscode/)Projetos com setup complexo

O que NÃO fazer

Anti-pattern 1: console.log de objetos mutáveis

JavaScript
// 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.
JavaScript
// 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

JavaScript
// 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;
}
JavaScript
// 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

JavaScript
// 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
  // ...
}
JavaScript
// 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.

JavaScript
// 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.

  1. Identifique a entrada (request, evento, prop) e a saída esperada.
  2. Coloque UM breakpoint no meio do caminho.
  3. Se o estado está correto ali, o bug está na segunda metade. Se está errado, está na primeira.
  4. 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.

JavaScript
// 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.

Bash
# 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.txt

O 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.

Marcos Soares

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

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.