Pular para o conteúdo
Node.js

Node.js: teste de sobrecarga de site

Paloma Macetko
Ilustração colorida de um unicórnio maestro regendo um bando de corujas mágicas que voam em formação contra um castelo-servidor, com uma ampulheta sobre o portão e um pergaminho com gráfico de barras ao lado

Olá meus Unicórnios! 🦄✨

Sabe aquela pergunta que todo mundo faz antes de uma campanha, de uma Black Friday, de um lançamento? "O site aguenta?" 😅 E sabe a resposta honesta que quase ninguém tem? "Sei lá."

Foi por aí que eu comecei. Queria um número — não um palpite. E queria um número que fizesse sentido para site, não para API: com navegador de verdade, executando o JavaScript, baixando as imagens, montando o layout. Porque o usuário não faz um GET e vai embora feliz; ele espera a página aparecer.

Então montei um teste de carga com Node.js e Puppeteer. Neste artigo eu conto como ele funciona — e, principalmente, conto as duas armadilhas que fizeram meus primeiros números serem lindos, redondos e completamente errados. 🤦‍♀️

Exemplos_SobrecargaSite no GitHubO teste completo e um servidor local descartável, para você medir sem apontar para site alheio.github.com

🤔 Por que navegador de verdade, e não um curl em loop

Essa é a primeira decisão do projeto, e ela custa caro — então precisa de motivo.

Ferramenta de carga clássica (ab, k6, wrk) dispara requisição HTTP. É rapidíssima: uma máquina modesta gera milhares de requisições por segundo. Puppeteer sobe Chrome. Cada aba come RAM, CPU, e demora centenas de milissegundos só para existir.

Só que elas medem coisas diferentes. Repare no que o curl vê no meu servidor local:

$ for i in 1 2 3 4 5; do curl -s -o /dev/null -w "%{time_total}s\n" http://127.0.0.1:8031/; done
0.005305s
0.005564s
0.004999s
0.006372s
0.005657s

Cinco milissegundos. Maravilha! Só que isso é o tempo do HTML chegar. Não é o tempo da página ficar pronta — que inclui o CSS, as imagens, as fontes, o JavaScript rodando. Se o seu gargalo mora ali, o curl nunca vai te contar. Ele mede o primeiro byte e vai embora satisfeito.

A regra que eu uso hoje:

  • Quer saber quantas requisições o servidor aguenta? Use k6/ab. É a ferramenta certa e é honesta na escala.
  • Quer saber como a página se comporta para o usuário sob carga? Puppeteer. Poucas instâncias, mas cada uma é um usuário de verdade.

Este artigo é sobre o segundo caso. E já aviso: com navegador você não vai simular 10.000 usuários numa máquina só. Quem promete isso está medindo outra coisa. 😉

⚙️ O config: todo o comportamento em um arquivo

A coisa mais útil que eu fiz nesse projeto foi não ter nada hardcoded. Todo o teste vive num config.json:

{
  "url": "https://exemplo.com.br/",
  "instances": 10,
  "headless": true,
  "timeout": 30000,
  "waitUntil": "networkidle2",
  "concurrency": 5,
  "delayBetweenLaunches": 500,
  "screenshotOnError": false,
  "verbose": true
}

Cada chave, em português claro:

  • instances — quantas visitas no total.
  • concurrency — quantas ao mesmo tempo. É esta que representa a carga; a de cima é só o tamanho da amostra.
  • waitUntil — o critério de "a página carregou". Guarde este nome: é a armadilha número um.
  • delayBetweenLaunches — pausa entre largadas. Armadilha número dois.
  • timeout — quanto esperar antes de desistir de uma visita.
  • headless — sem janela. Deixe true; abrir 10 Chromes visíveis é lindo mas custa CPU que era do teste.

O motivo de tudo estar em arquivo é bem prático: testar é comparar. Você roda, muda uma chave, roda de novo. Se para isso precisar editar código, você vai acabar mudando duas coisas sem querer — e aí não sabe mais qual causou a diferença. Foi exatamente trocando uma chave por vez que eu achei as duas armadilhas.

🚦 A janela de concorrência

Esse é o coração do script, e é onde erra quem escreve isso às pressas. A tentação é fazer:

// NÃO faça isso
await Promise.all(
  Array.from({ length: cfg.instances }, (_, i) => visitar(navegador, cfg, i))
);

Isso larga todas as visitas de uma vez. Com instances: 10 parece funcionar. Com instances: 200, você abre 200 abas simultâneas, a máquina engasga, e os tempos que voltam são o sofrimento do seu notebook, não o do servidor. 😵

O que eu quero é uma janela: no máximo concurrency visitas em voo; quando uma termina, a próxima larga. A implementação é curtinha:

const emVoo = new Set();

for (let i = 0; i < cfg.instances; i++) {
  const tarefa = visitar(navegador, cfg, i).then((r) => {
    resultados.push(r);
    emVoo.delete(tarefa); // libera a vaga
  });
  emVoo.add(tarefa);

  // Segura a proxima largada ate abrir vaga na janela.
  if (emVoo.size >= cfg.concurrency) await Promise.race(emVoo);
}
await Promise.all(emVoo); // espera o resto

Dois detalhes que parecem bobos e não são:

1. É Promise.race, não Promise.all. race volta quando a primeira termina — que é exatamente quando abre uma vaga. Se ali fosse all, o script esperaria as cinco terminarem para largar as cinco seguintes: viraria um teste em lotes, com a carga caindo a zero entre eles. Parece detalhe, muda o teste inteiro.

2. A remoção do Set mora dentro do .then(). Assim a vaga só é liberada quando a visita realmente acabou. E como visitar() nunca lança (já chegamos lá), não existe promessa rejeitada solta derrubando o processo.

💥 A função que nunca lança

Numa ferramenta de teste de carga, erro não é acidente: é resultado. Timeout é dado. Status 502 é dado. Se uma visita estourar e derrubar o script, você perdeu as outras nove — e justamente na hora mais interessante, que é quando o site começa a falhar. 😬

Por isso visitar() devolve sempre um objeto e nunca propaga exceção:

async function visitar(navegador, cfg, indice) {
  const inicio = performance.now();
  const pagina = await navegador.newPage();

  try {
    const resposta = await pagina.goto(cfg.url, {
      waitUntil: cfg.waitUntil,
      timeout: cfg.timeout,
    });

    const ms = Math.round(performance.now() - inicio);
    // Sem resposta HTTP nao ha o que medir: trata como falha.
    const status = resposta?.status() ?? 0;
    const ok = status >= 200 && status < 400;

    return { ok, status, ms };
  } catch ({ message }) {
    const ms = Math.round(performance.now() - inicio);
    const [erro] = message.split('\n');
    return { ok: false, status: 0, ms, erro };
  } finally {
    // A aba precisa fechar mesmo em erro, senao cada falha vaza memoria.
    await pagina.close().catch(() => {});
  }
}

Quatro coisas aqui evitam bug de verdade:

O finally fecha a aba mesmo em erro. Sem isso, cada falha deixa uma aba viva. Em 200 visitas com timeout, o Chrome vira um monstro de memória e a máquina começa a falhar sozinha — você passa a medir o vazamento do seu teste. 🙃

O .catch(() => {}) no close() parece paranoia, e não é. Fechar uma aba que já morreu também rejeita — e o finally é o único lugar onde uma exceção engole o return que já estava pronto. Seria o cúmulo: perder o resultado da visita justamente na hora de limpar. Um .catch() vazio diz melhor que um try vazio o que a gente quer ali: ignore, de propósito.

O ms é medido também no catch. Uma visita que estourou em 30s gastou 30 segundos do servidor. Jogar isso fora é perder o dado mais importante do teste.

É performance.now(), não Date.now(). O Date.now() segue o relógio do sistema — se o NTP ajustar a hora no meio de um teste longo, sua medição anda junto (e pode até dar tempo negativo 😬). O performance.now() é monotônico: só anda para frente, e vem com casas decimais. Por isso o Math.round(), que é onde eu escolho a precisão em vez de deixar 117.4283ms vazar para o relatório.

📊 Média mente. Use percentil.

Agora a parte que muda como você lê o resultado.

Se 9 visitas levam 100ms e 1 leva 3 segundos, a média dá 390ms — e ninguém teve 390ms de experiência. Nove pessoas tiveram um site rápido e uma teve um site quebrado, e a média apagou as duas informações. 😑

Percentil não apaga. p90 = 90% das visitas foram pelo menos tão rápidas quanto isso. É a frase que interessa: "nove em cada dez usuários esperaram menos que X".

const percentil = (ordenados, p) =>
  ordenados[Math.min(ordenados.length - 1, Math.ceil((p / 100) * ordenados.length) - 1)] ?? 0;

O Math.min ali não é decorativo: em p99 com poucas amostras, o índice calculado passa do fim do array e você leria undefined. E undefined no relatório não explode — só imprime, silenciosamente errado, que é bem pior. 😉

O ?? 0 no fim cobre o array vazio (todas as visitas falharam), caso em que o índice vira -1. Repare que é ?? e não ||: com ||, um percentil legítimo de 0ms seria trocado por... 0. Dá no mesmo por acidente aqui, mas é o tipo de hábito que morde noutro lugar. 😅

😳 Duas armadilhas que fizeram meu número mentir

Se você só for ler um pedaço deste artigo, leia este. 🙏 Porque as duas coisas abaixo não dão erro, não dão aviso, e produzem um relatório bonito — medindo o teste em vez do site.

Armadilha 1: o waitUntil cobra o tempo dele

Rodei o script contra o meu servidor local. Aquele mesmo que o curl respondeu em 5ms. Olha o que veio:

Alvo        : http://127.0.0.1:8031/
Instancias  : 10
Concorrencia: 5
waitUntil   : networkidle2   timeout: 30000ms

#001  OK    status=200  784ms
#002  OK    status=200  1024ms
#003  OK    status=200  1024ms
#004  OK    status=200  1024ms
#005  OK    status=200  1024ms
#006  OK    status=200  1024ms
#007  OK    status=200  1024ms
#008  OK    status=200  1024ms
#009  OK    status=200  808ms
#010  OK    status=200  1024ms

===== RESULTADO =====
Sucesso       : 10  (100.0%)
Tempo medio   : 978ms
Mediana (p50) : 1024ms
p90           : 1024ms

Olha esses números. 1024, 1024, 1024, 1024… oito visitas com o valor idêntico. 🤨

Servidor de verdade não responde assim. Servidor de verdade varia. Quando o seu resultado fica redondo desse jeito, ele não está medindo o servidor — está medindo um relógio do seu próprio teste.

E estava. networkidle2 significa "considere carregado quando ficarem no máximo 2 conexões abertas por 500ms seguidos". Ou seja: mesmo que a página fique pronta instantaneamente, o Puppeteer ainda espera meio segundo para ter certeza. Some a isso o custo de abrir a aba e navegar, e você chega no ~1 segundo fixo que apareceu ali.

A prova: mesmo servidor, mesma carga, só troquei o waitUntil para domcontentloaded:

Tempo min     : 50ms
Tempo medio   : 72ms
Mediana (p50) : 72ms
p90           : 85ms
p99           : 96ms

Mediana de 1024ms virou 72ms. Catorze vezes menor, no mesmo servidor, na mesma máquina. A diferença toda era a régua. 📏

Então qual usar? Depende honestamente do que você quer saber:

  • domcontentloaded — o HTML chegou e foi parseado. Mais perto de "o servidor respondeu".
  • load — imagens e CSS incluídos. Boa média para site de conteúdo.
  • networkidle2 — a rede sossegou. Útil em SPA que busca dados depois de renderizar, mas sempre soma os 500ms de espera.

A regra que eu levo daqui: o waitUntil não é detalhe de configuração, é a definição do que você está medindo. E comparar dois testes com waitUntil diferentes é comparar coisa nenhuma.

Armadilha 2: o delayBetweenLaunches estrangula o teste

Essa foi erro meu, e do tipo que dá vergonha. 😳

Eu tinha delayBetweenLaunches: 500 — meio segundo entre cada largada, para "não ser agressiva demais". Parece prudente. E é, para um teste suave. O problema é o que isso faz com a concorrência.

Pensa comigo: as visitas duravam ~100ms, e eu largava uma a cada 500ms. Quando a segunda começava, a primeira já tinha acabado há muito tempo. Minha janela de concorrência 5 nunca chegou perto de ter 5 em voo — na prática eu tinha uma visita de cada vez. Eu configurei "5 usuários simultâneos" e testei "1 usuário paciente". 🤦‍♀️

Mesmo teste, só zerando o delay:

com delayBetweenLaunches = 500ms
  Mediana (p50) : 72ms       Duracao total : 5.3s     Vazao : 1.90 visitas/s

com delayBetweenLaunches = 0
  Mediana (p50) : 220ms      Duracao total : 0.7s     Vazao : 14.17 visitas/s

A vazão foi de 1,90 para 14,17 visitas por segundo — mais de 7 vezes. O gargalo não era o site: era o meu setTimeout. 😅

E repare no detalhe mais bonito da tabela: a mediana subiu, de 72ms para 220ms. Isso não é defeito — é o teste finalmente funcionando. Com carga real e simultânea, cada visita passa a esperar as outras. Página mais lenta e vazão muito maior ao mesmo tempo é a assinatura da concorrência de verdade.

Moral: delayBetweenLaunches serve para rampa (subir a carga aos poucos, para ver onde quebra), não para o teste de pico. Se o delay for maior que a duração de uma visita, sua concurrency é ficção — o número está lá no config, mas nunca acontece.

🧾 O relatório

O relatório fecha com as falhas agrupadas por motivo, que é o que a gente realmente lê quando algo dá errado:

if (falhas.length) {
  const porMotivo = Map.groupBy(falhas, (f) => f.erro ?? `HTTP ${f.status}`);
  console.log('\nFalhas por motivo:');
  for (const [motivo, lista] of porMotivo) {
    console.log(`  ${lista.length}x  ${motivo}`);
  }
}

Trinta timeouts iguais é uma informação ("o servidor parou de responder"), não trinta. Agrupar transforma parede de texto em diagnóstico.

E o script termina com process.exit(falhas ? 1 : 0) — código de saída 1 se algo falhou. É o que permite pendurar isso num pipeline de CI e ter o build vermelho quando o site regride. 🚦

📜 O script completo

Aqui está o arquivo inteiro. É exatamente este que produziu todas as saídas acima — as duas armadilhas foram descobertas rodando ele.

Ele usa ESM (import, não require), que é o padrão do Node moderno, e aproveita coisas que a linguagem ganhou e pouca gente usa em script de automação: await no topo do arquivo — sem aquele main().catch() envolvendo tudo —, timers/promises no lugar do setTimeout embrulhado à mão, e Array.at(-1) para pegar o último elemento. Rodei no Node 24; do 21 para cima funciona igual.

Instalação:

npm init -y
npm pkg set type=module
npm install puppeteer

node load-test.mjs              # usa ./config.json
node load-test.mjs outro.json   # ou um config especifico
/**
 * Teste de sobrecarga de site com Puppeteer.
 *
 * Sobe N navegadores reais contra uma URL, respeitando uma janela de
 * concorrencia, e mede quanto tempo cada visita levou.
 *
 * Uso:  node load-test.mjs [config.json]
 */

import { readFile } from 'node:fs/promises';
import { setTimeout as dormir } from 'node:timers/promises';
import puppeteer from 'puppeteer';

const PADRAO = {
  url: 'https://example.com/',
  instances: 10,
  concurrency: 5,
  waitUntil: 'networkidle2',
  timeout: 30_000,
  delayBetweenLaunches: 500,
  headless: true,
  verbose: true,
};

async function carregarConfig(arquivo = new URL('config.json', import.meta.url)) {
  try {
    return { ...PADRAO, ...JSON.parse(await readFile(arquivo, 'utf8')) };
  } catch (erro) {
    console.error(`Config invalido: ${erro.message}`);
    process.exit(1);
  }
}

/** Executa uma visita e devolve SEMPRE um resultado, nunca lanca. */
async function visitar(navegador, cfg, indice) {
  const rotulo = `#${String(indice + 1).padStart(3, '0')}`;
  const inicio = performance.now();
  const pagina = await navegador.newPage();

  try {
    const resposta = await pagina.goto(cfg.url, {
      waitUntil: cfg.waitUntil,
      timeout: cfg.timeout,
    });

    const ms = Math.round(performance.now() - inicio);
    const status = resposta?.status() ?? 0;
    const ok = status >= 200 && status < 400;

    if (cfg.verbose) {
      console.log(`${rotulo}  ${ok ? 'OK  ' : 'FALHA'}  status=${status}  ${ms}ms`);
    }
    return { ok, status, ms };
  } catch ({ message }) {
    const ms = Math.round(performance.now() - inicio);
    const [erro] = message.split('\n');

    if (cfg.verbose) console.log(`${rotulo}  ERRO   ${erro}  ${ms}ms`);
    return { ok: false, status: 0, ms, erro };
  } finally {
    // A aba precisa fechar mesmo em erro, senao cada falha vaza memoria.
    await pagina.close().catch(() => {});
  }
}

const percentil = (ordenados, p) =>
  ordenados[Math.min(ordenados.length - 1, Math.ceil((p / 100) * ordenados.length) - 1)] ?? 0;

function relatorio(resultados, ms, cfg) {
  const ok = resultados.filter((r) => r.ok);
  const falhas = resultados.filter((r) => !r.ok);
  const tempos = ok.map((r) => r.ms).sort((a, b) => a - b);
  const pct = (ok.length / resultados.length) * 100;

  console.log(`
===== RESULTADO =====
URL           : ${cfg.url}
Visitas       : ${resultados.length}  (concorrencia ${cfg.concurrency})
Sucesso       : ${ok.length}  (${pct.toFixed(1)}%)
Falhas        : ${falhas.length}`);

  if (tempos.length) {
    const media = tempos.reduce((a, b) => a + b, 0) / tempos.length;
    console.log(`Tempo min     : ${tempos.at(0)}ms
Tempo medio   : ${Math.round(media)}ms
Mediana (p50) : ${percentil(tempos, 50)}ms
p90           : ${percentil(tempos, 90)}ms
p99           : ${percentil(tempos, 99)}ms
Tempo max     : ${tempos.at(-1)}ms`);
  }

  console.log(`Duracao total : ${(ms / 1000).toFixed(1)}s
Vazao         : ${(resultados.length / (ms / 1000)).toFixed(2)} visitas/s`);

  if (falhas.length) {
    const porMotivo = Map.groupBy(falhas, (f) => f.erro ?? `HTTP ${f.status}`);
    console.log('\nFalhas por motivo:');
    for (const [motivo, lista] of porMotivo) {
      console.log(`  ${lista.length}x  ${motivo}`);
    }
  }
  return falhas.length;
}

const cfg = await carregarConfig(process.argv[2]);

console.log(`Alvo        : ${cfg.url}
Instancias  : ${cfg.instances}
Concorrencia: ${cfg.concurrency}
waitUntil   : ${cfg.waitUntil}   timeout: ${cfg.timeout}ms
`);

const navegador = await puppeteer.launch({
  headless: cfg.headless,
  args: ['--no-sandbox', '--disable-dev-shm-usage'],
});

const resultados = [];
const emVoo = new Set();
const comecou = performance.now();

try {
  for (let i = 0; i < cfg.instances; i++) {
    const tarefa = visitar(navegador, cfg, i).then((r) => {
      resultados.push(r);
      emVoo.delete(tarefa); // libera a vaga
    });
    emVoo.add(tarefa);

    // Segura a proxima largada ate abrir vaga na janela.
    if (emVoo.size >= cfg.concurrency) await Promise.race(emVoo);
    if (cfg.delayBetweenLaunches) await dormir(cfg.delayBetweenLaunches);
  }
  await Promise.all(emVoo);
} finally {
  await navegador.close();
}

process.exit(relatorio(resultados, performance.now() - comecou, cfg) ? 1 : 0);

Uma escolha de projeto que vale explicar: um navegador só, várias abas. Subir um Chrome inteiro por visita seria mais isolado, mas o custo de inicialização (centenas de ms cada) passaria a dominar a medição — você mediria o Chrome bootando, não o site. Abas compartilham o processo e são bem mais baratas. Se você precisa de isolamento total de cookies e cache, o caminho é createBrowserContext(), não um navegador por visita.

🎯 Como eu uso isso na prática

O erro clássico é rodar uma vez, ver "100% de sucesso" e declarar vitória. Um teste de carga sozinho não diz nada — ele só vale comparado. Dois usos que realmente rendem:

Achar o joelho da curva. Rode com concurrency 1, 5, 10, 20, 40, sempre com o mesmo waitUntil. Vá anotando o p90. Enquanto ele fica estável, sobra fôlego. O ponto em que o p90 dispara é a capacidade real do site — e é o número que responde "o site aguenta?".

Guardar a linha de base. Anote o p90 de hoje. Depois do próximo deploy, rode igual. Se dobrou, alguma coisa entrou junto — e você descobre antes da campanha, não durante. 🙌

Duas cautelas para o número não te enganar de novo:

  • A máquina do teste também tem limite. Se a CPU do seu notebook estiver em 100%, o gargalo é ele. Antes de acreditar num p90 ruim, confira que a máquina que gera a carga não é a que está sofrendo.
  • Cuidado com o cache/CDN. Dez visitas iguais na mesma URL podem estar batendo todas no cache e nunca tocar o servidor. Aí você mediu o CDN — que aguenta mesmo. 😄

✅ O que eu levo desse projeto

Se eu pudesse colar um bilhete na tela de quem vai escrever um teste de carga pela primeira vez, seria este:

O número mais perigoso não é o ruim — é o bonito. Um p90 alto te faz investigar. Um "1024ms" redondinho, repetido oito vezes, você anota na planilha e vai dormir tranquila. E era o teste medindo o próprio relógio. 😅

Por isso as duas perguntas que eu faço hoje antes de acreditar em qualquer resultado:

  1. Esse número varia? Se todas as visitas dão quase o mesmo valor, desconfie: você achou um setTimeout, não um servidor.
  2. A concorrência que configurei aconteceu mesmo? Se o delay entre largadas é maior que a duração de uma visita, ela não aconteceu.

Levei um bom tempo para desconfiar daquele 1024ms — ele estava ali, na minha frente, gritando que era artificial. Hoje é a primeira coisa que eu olho. 💡

💾 O repositório

O teste está lá inteiro, junto com um servidor local descartável — assim você reproduz as duas armadilhas na sua máquina, sem apontar carga para o site de ninguém:

Exemplos_SobrecargaSite no GitHubSuba o alvo, rode o teste e veja o 1024ms aparecer — e sumir ao trocar o waitUntil.github.com

Por hoje é só, meus unicórnios! 🦄✨

Que a magia do arco-íris continue brilhando em suas vidas! Até mais! 🌈🌟

Leia também