Node.js: teste de sobrecarga de site
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. 🤦♀️
🤔 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. Deixetrue; 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:
- Esse número varia? Se todas as visitas dão quase o mesmo valor, desconfie: você achou um
setTimeout, não um servidor. - 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:
Por hoje é só, meus unicórnios! 🦄✨
Que a magia do arco-íris continue brilhando em suas vidas! Até mais! 🌈🌟
Leia também
Node.js: alterando o plano de energia do Windows
Como ler e trocar o plano de energia do Windows com Node.js e powercfg: por que resolver por GUID, e o acento que o TextDecoder nao decodifica.
Node.js: exportando grandes volumes do Elasticsearch
Como exportar milhões de registros do Elasticsearch para CSV com Node.js: a parede dos 10.000, a Scroll API e o scroll que fica aberto.
Node.js: gerando certificado SSL e instalando no IIS
Como emitir um certificado gratuito do Let's Encrypt com Node.js e instalar no IIS do Windows, com as armadilhas que travaram tudo.