Teste de velocidade da internet no navegador
Olá meus Unicórnios! 🦄✨
Sabe aquele momento em que a chamada de vídeo trava, todo mundo vira quadradinho, e alguém pergunta "será que é a minha internet?" 😅 Pois é. Eu queria uma página simples, minha, que respondesse isso — sem instalar nada, sem depender de site de terceiro, aberta direto no navegador.
Parecia coisa de meia hora. Não foi. 🙃 Não porque medir seja difícil, mas porque medir errado é facílimo — e o pior é que o resultado errado tem cara de resultado certo. Ele aparece bonito na tela, com uma casa decimal e tudo, e você acredita.
Este artigo é o caminho inteiro: como medir download, upload e latência com JavaScript puro, e principalmente as armadilhas que fazem o número mentir. Sem biblioteca nenhuma — fetch e performance.now() já vêm no navegador.
🧭 A ideia, em uma frase
Medir velocidade de rede é uma regra de três com cronômetro:
velocidade = quantidade de dados ÷ tempo gasto
Só isso. Baixa um arquivo, marca quanto tempo demorou, divide. O que complica é que cada uma dessas três palavras tem uma armadilha embutida: "quantidade" pode ser em bits ou bytes, "tempo" depende de qual relógio você usa, e "baixa" pode nem chegar a acontecer se o navegador resolver responder do cache.
São três peças no servidor, cada uma com um trabalho bobo, e um arquivo de JavaScript que cronometra as três.
📥 O servidor: três arquivos que não fazem quase nada
Para medir download eu preciso de algo para baixar. Não precisa ser um arquivo de verdade no disco — é melhor gerar os bytes na hora, porque assim eu escolho o tamanho pela URL.
Repare nos dois cuidados logo no topo, que são os que mais doem quando faltam:
<?php
// Gera bytes na hora para o teste de download. O tamanho vem pela URL: ?mb=10
// Se o servidor comprimir, ele manda pouco pela rede e o navegador entrega
// muito para o seu codigo. A conta sai gloriosamente falsa.
ini_set('zlib.output_compression', '0');
header('Content-Type: application/octet-stream');
// no-store nas duas pontas: aqui e no fetch. A que faltar e por onde o cache volta.
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
header('Pragma: no-cache');
O zlib.output_compression desligado é o primeiro cuidado. Se o servidor comprimir a resposta, ele manda 200 KB pela rede e o navegador entrega 10 MB para o seu código — os bytes "incharam" na chegada. Você dividiria 10 MB por um tempo que corresponde a 200 KB e publicaria uma velocidade gloriosamente falsa. Como aqui os bytes são todos iguais, a compressão seria brutal: é o cenário perfeito para a mentira.
O segundo é o no-store, e ele merece seção própria mais abaixo.
O resto é um laço que despeja blocos de 64 KB até completar o tamanho pedido:
$mb = isset($_GET['mb']) ? (int) $_GET['mb'] : 10;
// A URL e publica: sem teto, qualquer um pede ?mb=100000 e o servidor
// passa a tarde gerando bytes de graca para um estranho.
if ($mb < 1) {
$mb = 1;
}
if ($mb > 50) {
$mb = 50;
}
$total = $mb * 1024 * 1024;
header('Content-Length: ' . $total);
$pedaco = str_repeat('a', 64 * 1024);
$enviado = 0;
while ($enviado < $total) {
$falta = $total - $enviado;
if ($falta < strlen($pedaco)) {
echo substr($pedaco, 0, $falta);
$enviado += $falta;
} else {
echo $pedaco;
$enviado += strlen($pedaco);
}
flush();
// Se o visitante fechou a aba, paramos aqui. Sem esta checagem o PHP
// continua gerando os 50 MB inteiros falando sozinho.
if (connection_aborted()) {
break;
}
}
Duas coisas que eu não colocaria se não tivesse pensado nelas antes:
O teto de 50 MB existe porque a URL é pública. Sem ele, qualquer pessoa pede ?mb=100000 e o seu servidor passa a tarde gerando bytes de graça para um estranho. 😬
O connection_aborted() é a limpeza que quase ninguém escreve. Se o visitante fecha a aba no meio do download, sem essa checagem o PHP continua gerando os 50 MB até o fim, falando sozinho para uma conexão que já morreu. Com ela, o laço para na hora.
📤 Recebendo o upload sem estourar a memória
O upload é o inverso: o navegador manda os bytes e o servidor só precisa contá-los e jogar fora. A parte interessante é como ler:
$limite = 50 * 1024 * 1024;
$entrada = fopen('php://input', 'rb');
if ($entrada === false) {
http_response_code(400);
echo json_encode(array('erro' => 'sem corpo'));
exit;
}
$recebido = 0;
// Ler em pedacos de 64 KB evita o PHP montar o corpo inteiro na memoria
// antes de voce tocar nele.
while (!feof($entrada)) {
$pedaco = fread($entrada, 64 * 1024);
if ($pedaco === false || $pedaco === '') {
break;
}
$recebido += strlen($pedaco);
// Conto os bytes que REALMENTE chegaram. Content-Length e so o que o
// cliente alega que vai mandar.
if ($recebido > $limite) {
break;
}
}
fclose($entrada);
echo json_encode(array('bytes' => $recebido));
Ler de php://input em pedaços de 64 KB não é frescura: é o que evita o PHP montar os 5 MB inteiros na memória antes de você tocar neles. Com uploads maiores, deixar o PHP fazer isso sozinho é pedir estouro de memória.
E repare no segundo if: eu conto os bytes que realmente chegaram e corto se passar do limite. Confiar no cabeçalho Content-Length seria ingênuo — ele é só o que o cliente alega que vai mandar. 🙂
A latência é o arquivo mais simples dos três. Ele existe só para responder rápido:
<?php
// Existe so para responder rapido. Quanto menos ele fizer, mais honesta
// fica a medicao: o que queremos cronometrar e a ida e volta, nao o servidor.
header('Content-Type: text/plain');
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
header('Pragma: no-cache');
echo 'pong';
⏱️ O relógio certo: performance.now(), nunca Date.now()
Essa é sutil e vale para qualquer medição de tempo, não só para rede.
Date.now() devolve a hora do relógio do sistema — e o relógio do sistema pode ser ajustado no meio da sua medição. O Windows sincroniza com a internet de tempos em tempos; se isso acontecer entre o seu início e o seu fim, o tempo medido pode encolher, esticar ou até voltar. Um teste que dura 3 segundos e cai bem em cima de um ajuste vira um número sem sentido.
performance.now() é um cronômetro monotônico: ele só anda para a frente, não liga para o relógio do sistema, e vem com casas decimais de milissegundo. É o certo aqui — e é de graça.
// O navegador conta BYTES. A internet se vende em BITS por segundo.
// Um byte tem 8 bits: sem este * 8 o resultado sai oito vezes menor.
function paraMbps(bytes, milissegundos) {
return (bytes * 8) / (milissegundos / 1000) / 1000000;
}
// performance.now() e um cronometro monotonico: so anda para a frente e nao
// liga para o relogio do sistema. Date.now() pode ser ajustado no meio da
// medicao e devolver um tempo que encolheu, esticou ou voltou.
function cronometro() {
var inicio = performance.now();
return function () {
return performance.now() - inicio;
};
}
Esse * 8 aí no meio é o próximo assunto, e é o erro mais comum de todos.
🔢 Bit e byte: o fator 8 que derruba todo mundo
Aqui está a pegadinha que faz o seu resultado sair oito vezes menor que o do site de teste do seu provedor — e você passar meia hora achando que a internet piorou. 😳
A regra é curta:
- O navegador conta em bytes (
arrayBuffer().byteLengthdevolve bytes). - A internet se vende em bits por segundo (o "100 mega" do seu plano é 100 megabits).
- Um byte tem 8 bits. Logo: multiplique por 8.
Rodei a mesma medição com e sem o 8 para ver o tamanho do estrago. Dez megabytes em 37,5 ms:
com o × 8 : 2237.0 Mbps
sem o × 8 : 279.6 Mbps
razão : 8.0
Oito vezes exatos, como manda a matemática. 🤯 O detalhe cruel é que o número menor continua perfeitamente plausível — não parece bug nenhum. Numa conexão doméstica, onde o valor certo seria 60 mega, a versão sem o 8 mostraria 7,5 e você pensaria "a internet caiu hoje". E está errado por 800%.
Por isso eu isolei a conta numa função só, com o comentário em cima da linha onde a armadilha mora.
🗄️ O cache: a mentira mais bonita de todas
Se você só for ler um pedaço deste artigo, leia este. 🙏
Você monta o teste, roda, dá 300 Mbps. Roda de novo — 4.000 Mbps. Roda uma terceira vez e dá 5.000. Sua internet não ficou dezesseis vezes mais rápida em vinte segundos: o navegador simplesmente parou de baixar. Ele guardou o arquivo na primeira vez e agora entrega do disco, sem tocar na rede. Você está medindo a velocidade do seu SSD. 😅
Eu quis ver isso acontecendo em vez de acreditar. Então subi um endpoint que permite cache de propósito e baixei quatro vezes seguidas da mesma URL, perguntando ao navegador quantos bytes tinham realmente cruzado a rede. Quem responde isso é a Resource Timing API:
// Baixa quatro vezes a MESMA URL (sem quebra-cache de proposito) e pergunta
// ao navegador quantos bytes cruzaram a rede em cada uma.
async function provarCache(url) {
for (var i = 0; i < 4; i++) {
var resposta = await fetch(url);
var dados = await resposta.arrayBuffer();
// Quem responde isso e a Resource Timing API. transferSize zerado com
// decodedBodySize cheio e a assinatura do cache: o navegador entregou
// os bytes sem tocar na rede.
var entradas = performance.getEntriesByName(new URL(url, location.href).href);
var medida = entradas[entradas.length - 1];
console.log(medida.transferSize, medida.decodedBodySize, dados.byteLength);
}
}
O resultado, das quatro baixadas na mesma URL:
transferSize decodedBodySize veio do cache?
10486060 10485760 não
0 10485760 SIM
0 10485760 SIM
0 10485760 SIM
Olhe para a segunda linha em diante: zero bytes trafegaram, e mesmo assim o navegador entregou 10.485.760 bytes para o meu código. É exatamente isso que infla o resultado — o seu programa "recebeu" 10 MB que nunca saíram do lugar.
E aqui vai o detalhe que me pegou de surpresa: na medição local o tempo quase não mudou (62 ms na primeira, 40 ms nas seguintes), porque o disco e o loopback têm velocidades parecidas. Ou seja: em cima da mesa a armadilha é quase invisível. Ela só aparece com força numa conexão de verdade — e aí você já publicou o número errado.
Teve ainda uma segunda surpresa, e essa vale ouro: na primeira vez que montei essa prova, ela não reproduziu. As quatro baixadas vieram com transferSize cheio, como se o cache não existisse. O motivo é que eu estava rodando o navegador num perfil descartável, criado do zero e jogado fora no fim — e um perfil sem disco não tem onde guardar cache. Foi só rodar num perfil persistente, o mesmo tipo que qualquer visitante usa, para os zeros aparecerem. A armadilha que eu queria demonstrar estava escondida atrás de outra. 🙃
A defesa é dupla, e as duas metades importam:
// A defesa contra o cache tem duas metades e as duas importam.
function urlSemCache(caminho) {
var separador = caminho.indexOf('?') === -1 ? '?' : '&';
// O parametro aleatorio e o cinto de seguranca: como a URL nunca se
// repete, nao existe entrada de cache para acertar -- nem no navegador,
// nem em algum proxy no caminho que nunca ouviu falar do seu no-store.
return caminho + separador + '_=' + Math.random().toString(36).slice(2);
}
function buscarSemCache(caminho, opcoes) {
var config = opcoes || {};
config.cache = 'no-store';
return fetch(urlSemCache(caminho), config);
}
O cache: 'no-store' pede educadamente ao navegador que não guarde. O parâmetro aleatório na URL é o cinto de segurança: como a URL nunca se repete, não existe entrada de cache para acertar — nem no navegador, nem em algum proxy no caminho que nunca ouviu falar do seu no-store.
📏 Arquivo pequeno mede latência, não banda
A tentação é usar um arquivo pequeno para o teste ser rapidinho. O problema é que todo download começa devagar: tem o tempo de ida e volta até o servidor, o aperto de mão da conexão, e o TCP subindo a velocidade aos poucos até descobrir quanto a rede aguenta.
Com um arquivo pequeno, o download acaba antes dessa subida terminar. Você mediu a partida, não a corrida.
Dá para ver isso muito bem baixando o mesmo conteúdo em dois tamanhos, três vezes cada:
1 MB : 912 Mbps · 1864 Mbps · 1553 Mbps
50 MB : 2981 Mbps · 2895 Mbps · 5230 Mbps
O arquivo de 1 MB reporta bem menos que o de 50 MB, no mesmo caminho e no mesmo instante. Nenhum dos dois está com defeito: o de 1 MB simplesmente passou a maior parte da vida na fase de aceleração.
Repare também na diferença entre a primeira medição de 1 MB e as seguintes: 912, depois 1864 e 1553. A primeira pagou o custo de abrir a conexão, e as outras duas aproveitaram o caminho já aquecido. Arquivo maior dá número mais alto e sofre menos com esse começo. Por isso o padrão do meu teste é 10 MB para download e 5 MB para upload — o suficiente para sair da fase de aceleração sem fazer o visitante esperar.
📊 Latência: várias amostras e mediana
Latência é o tempo de ida e volta até o servidor. Medir uma vez só não vale nada: basta o Windows resolver atualizar algo naquele instante e a sua medição vira lixo.
Então eu meço dez vezes. E na hora de resumir as dez em um número, uso mediana, não média — essa escolha é o coração da seção:
// Mediana, nunca media: um pico isolado e normal em rede, e a media inteira
// vai atras dele. A mediana continua contando a experiencia real.
function medianaDe(valores) {
var ordenados = valores.slice().sort(function (a, b) {
return a - b;
});
var meio = Math.floor(ordenados.length / 2);
if (ordenados.length % 2 === 1) {
return ordenados[meio];
}
return (ordenados[meio - 1] + ordenados[meio]) / 2;
}
A diferença aparece com um exemplo. Imagine nove medições em torno de 20 ms e uma que travou em 800 ms:
amostras: 18, 19, 20, 21, 19, 20, 22, 18, 21, 800
média : 97,8 ms ← um pico só arrastou tudo
mediana : 20,0 ms ← a realidade
A média diz 97,8 ms e mente sobre a experiência de usar aquela conexão. A mediana diz 20 ms, que é o que você sente em nove de cada dez pacotes. Como um pico isolado é normal em rede, a mediana é a resposta honesta.
🚦 Medindo de verdade: as três funções
Com as armadilhas todas resolvidas, as medições ficam curtas. O download:
async function medirDownload() {
var parar = cronometro();
var resposta = await buscarSemCache('baixar.php?mb=' + MB_DOWNLOAD);
// Sem o arrayBuffer o await do fetch termina quando os CABECALHOS chegam --
// e eles chegam quase instantaneamente, muito antes dos 10 MB. Voce pararia
// o cronometro no comeco do download.
var dados = await resposta.arrayBuffer();
var ms = parar();
return {
mbps: paraMbps(dados.byteLength, ms),
bytes: dados.byteLength,
ms: ms
};
}
O await resposta.arrayBuffer() não é enfeite. Sem ele, o await fetch() termina assim que os cabeçalhos chegam — e os cabeçalhos chegam quase instantaneamente, muito antes dos 10 MB. Você pararia o cronômetro no começo do download e o resultado iria para a lua. É o mesmo erro do cache, por outra porta.
O upload tem a mesma lógica ao contrário:
async function medirUpload() {
var bytes = MB_UPLOAD * 1024 * 1024;
var carga = new Uint8Array(bytes);
var parar = cronometro();
var resposta = await buscarSemCache('enviar.php', {
method: 'POST',
body: carga,
headers: { 'Content-Type': 'application/octet-stream' }
});
// O json() cumpre aqui o papel do arrayBuffer la em cima: e a confirmacao
// de que o ultimo byte chegou do outro lado. Parar antes mediria a
// velocidade com que a maquina despejou os dados no buffer de saida.
var retorno = await resposta.json();
var ms = parar();
return {
mbps: paraMbps(bytes, ms),
bytes: retorno.bytes,
ms: ms
};
}
Aqui o await resposta.json() cumpre o papel do arrayBuffer: é a confirmação de que o último byte chegou do outro lado. Se eu parasse antes, mediria a velocidade com que o meu computador despejou os dados no buffer de saída — que não tem nada a ver com a velocidade com que eles atravessaram a rede.
E a latência, que junta as dez amostras e devolve a mediana:
async function medirLatencia() {
var amostras = [];
for (var i = 0; i < AMOSTRAS_LATENCIA; i++) {
var parar = cronometro();
var resposta = await buscarSemCache('ping.php');
await resposta.text();
amostras.push(parar());
}
return {
mediana: medianaDe(amostras),
amostras: amostras
};
}
Rodando contra o servidor local, as dez amostras saíram assim — e dá para ver de olho por que a mediana é a escolha certa:
amostras: 2.6, 1.8, 2.0, 1.7, 1.8, 1.5, 1.5, 1.5, 1.9, 1.7
mediana : 1.75 ms
🎬 Juntando tudo, com o erro tratado
O botão chama as três em sequência, atualizando a tela a cada etapa:
async function rodarTeste() {
var botao = document.getElementById('btnIniciar');
// Sem isto, um visitante ansioso clica tres vezes e dispara tres testes
// simultaneos que disputam a mesma banda -- cada um medindo um terco.
botao.disabled = true;
mostrar('download', '--');
mostrar('upload', '--');
mostrar('latencia', '--');
try {
mostrar('recado', 'Medindo a latencia...');
var latencia = await medirLatencia();
mostrar('latencia', latencia.mediana.toFixed(0) + ' ms');
mostrar('recado', 'Medindo o download...');
var download = await medirDownload();
mostrar('download', download.mbps.toFixed(1) + ' Mbps');
mostrar('recado', 'Medindo o upload...');
var upload = await medirUpload();
mostrar('upload', upload.mbps.toFixed(1) + ' Mbps');
mostrar('recado', 'Pronto!');
} catch (erro) {
// Se o servidor estiver fora do ar o fetch estoura seco. Sem este catch
// a pagina fica eternamente em "Medindo o download..." sem explicar nada.
mostrar('recado', 'Nao consegui medir: ' + erro.message);
}
botao.disabled = false;
}
document.getElementById('btnIniciar').addEventListener('click', rodarTeste);
O try/catch em volta das três não é burocracia. Se o servidor estiver fora do ar, o fetch estoura um erro seco — apontando para uma porta onde não há ninguém, a mensagem é TypeError: Failed to fetch. Sem o catch, a página ficaria eternamente em "Medindo o download..." e o visitante não faria ideia do porquê. 😞
Repare também no botao.disabled = true logo no começo e no false no fim, fora do try. Sem isso, um visitante ansioso clica três vezes e dispara três testes simultâneos que disputam a mesma banda — e cada um mede um terço da velocidade real.
Rodando aqui na minha máquina, contra um servidor local, a página fica assim:
Antes que alguém comemore: esses 4.510 Mbps não são a minha internet. 😄 Esse teste rodou contra um servidor na própria máquina, então os bytes nem chegaram perto de uma placa de rede — é a velocidade do loopback. Serve para provar que a medição funciona de ponta a ponta, e é ótimo para desenvolver; para saber a sua banda de verdade, o servidor precisa estar do outro lado da internet.
A latência de 2 ms conta a mesma história: contra um servidor real, esse número seria dezenas de milissegundos.
E repare que o número do download varia bastante entre execuções — no loopback ele depende do humor da máquina naquele segundo, e passeia por toda a faixa dos milhares. É mais um lembrete de que uma medição isolada não conta a história inteira, nem para banda nem para latência.
🧾 A página inteira
O HTML é de propósito modesto — três cartões, um botão e uma linha de recado. É só copiar:
<div class="cartoes">
<div class="cartao">
<div class="rotulo">Download</div>
<div class="valor" id="download">--</div>
</div>
<div class="cartao">
<div class="rotulo">Upload</div>
<div class="valor" id="upload">--</div>
</div>
<div class="cartao">
<div class="rotulo">Latencia</div>
<div class="valor" id="latencia">--</div>
</div>
</div>
<button id="btnIniciar">Iniciar teste</button>
<p id="recado">Clique no botao para comecar.</p>
<script src="velocidade.js"></script>
E no topo do velocidade.js ficam os três ajustes que você pode querer mexer:
var MB_DOWNLOAD = 10;
var MB_UPLOAD = 5;
var AMOSTRAS_LATENCIA = 10;
Os arquivos ficam todos na mesma pasta (index.html, velocidade.js, baixar.php, enviar.php e ping.php), e é só abrir pelo servidor — não pelo file://, senão o fetch não acha os endpoints.
E é isso: sem biblioteca, sem CDN, sem dependência nenhuma. Tudo que esse teste usa — fetch, performance.now(), Uint8Array — já estava no seu navegador o tempo todo. 🎉
🎯 As armadilhas, de uma vez
Se eu tivesse que resumir o que faz um teste desses mentir, é sempre uma destas cinco:
- O cache entrega o arquivo sem tocar na rede — parâmetro aleatório na URL, mais
no-storenas duas pontas. - O fator 8, porque o navegador conta bytes e a internet se vende em bits.
- O cronômetro errado:
performance.now(), nuncaDate.now(). - O arquivo pequeno, que acaba antes de a conexão acelerar e mede a partida.
- A amostra única de latência, e a média que um pico só destrói.
Todas as cinco têm a mesma assinatura: o número sai plausível. Nenhuma delas quebra a página, nenhuma aparece no console. É por isso que elas sobrevivem tanto tempo em código que "funciona". 🙂
Por hoje é só, meus unicórnios! 🦄✨
Que a magia do arco-íris continue brilhando em suas vidas! Até mais! 🌈🌟
Leia também
Node.js: populando uma planilha no Google Sheets
Como escrever dados numa planilha do Google Sheets com Node.js, e as tres armadilhas que transformam dado certo em planilha errada.
Node.js: consultando CPF e CNPJ no SPC
Como consultar CPF e CNPJ no SPC Brasil com Node.js puro: envelope SOAP na mao, sem biblioteca, e as armadilhas do caminho.
Testando câmera e microfone no navegador
Como pedir permissão, mostrar a prévia, gravar e reproduzir áudio e vídeo direto do navegador, com JavaScript puro e sem nenhuma biblioteca.