Pular para o conteúdo
JavaScript

Teste de velocidade da internet no navegador

Paloma Macetko
Ilustração colorida de um unicórnio de crina arco-íris correndo dentro de um cano de luz transparente, com uma coruja segurando uma ampulheta e um velocímetro mágico ao lado, sobre fundo azul claro

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().byteLength devolve 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:

Página do teste de velocidade com três cartões escuros mostrando download de 4510.0 Mbps, upload de 173.7 Mbps e latência de 2 ms, com o botão Iniciar teste embaixo

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-store nas duas pontas.
  • O fator 8, porque o navegador conta bytes e a internet se vende em bits.
  • O cronômetro errado: performance.now(), nunca Date.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

JavaScript

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.