A evolução das requisições: de XMLHttpRequest ao Fetch

[103] A evolução das requisições: de XMLHttpRequest ao Fetch

O fetch não caiu do céu. Antes dele foram quinze anos de XMLHttpRequest — cinco estados para verificar à mão, callbacks aninhados e o jQuery abstraindo a verbosidade. O artigo percorre essa linha do tempo, compara a mesma operação nas três eras e mostra o caso em que o XHR ainda vence: progresso de upload.
Javascript

15 min de leitura

Módulo 3 — JavaScript Assíncrono e APIs

Introdução

No artigo Fetch API: consumindo dados da internet aprendemos a Fetch API — moderna, elegante e baseada em Promises. Mas ela não surgiu do nada. Por mais de uma década, toda comunicação assíncrona entre o navegador e um servidor era feita com o XMLHttpRequest (XHR).

Entender essa história tem valor prático: você vai encontrar código legado com XHR em projetos reais. E mais importante — entender o problema que cada ferramenta resolve faz você apreciar e usar melhor o que temos hoje.

O começo: páginas que recarregavam tudo

Antes de 1999, toda interação com o servidor exigia recarregar a página inteira. Você clicava em "Ver mais comentários" e a página inteira era recarregada — perdendo scroll, estado, e causando aquela piscada incômoda.

O problema era simples: o HTTP é um protocolo de requisição-resposta. O navegador pedia, o servidor respondia com uma página HTML completa, e o navegador a renderizava do zero.

XMLHttpRequest — a revolução silenciosa (1999–2015)

Em 1999, a Microsoft introduziu o XMLHTTP no Internet Explorer 5 para o Outlook Web Access. Em 2005, Jesse James Garrett cunhou o termo AJAXAsynchronous JavaScript and XML — descrevendo a técnica de atualizar partes de uma página sem recarregar tudo. Em 2006 o W3C publicou o primeiro rascunho de uma especificação para o XMLHttpRequest — que na prática já era universal havia anos, implementado por imitação entre navegadores antes de existir norma alguma.

O Gmail (2004) e o Google Maps (2005) mostraram ao mundo o que era possível. A web nunca mais foi a mesma.

Veja como era uma requisição com XHR:

// Jeito clássico — XMLHttpRequest puro
var xhr = new XMLHttpRequest();

// Configura a requisição
xhr.open("GET", "https://jsonplaceholder.typicode.com/users/1", true);
// true = assíncrono

// Define o que fazer quando a resposta chegar
xhr.onreadystatechange = function() {
  // readyState 4 = requisição completa
  if (xhr.readyState === 4) {
    if (xhr.status === 200) {
      var usuario = JSON.parse(xhr.responseText);
      console.log("Nome:", usuario.name);
    } else {
      console.error("Erro:", xhr.status);
    }
  }
};

// Define handlers de erro
xhr.onerror = function() {
  console.error("Falha de rede.");
};

// Envia a requisição
xhr.send();

Funciona. Mas note o quanto é verboso e callback-driven.

Os estados do XMLHttpRequest

O XHR tinha cinco estados de progresso — e você precisava verificar manualmente:

var xhr = new XMLHttpRequest();

xhr.onreadystatechange = function() {
  switch (xhr.readyState) {
    case 0: console.log("UNSENT — objeto criado, open() não chamado"); break;
    case 1: console.log("OPENED — open() foi chamado"); break;
    case 2: console.log("HEADERS_RECEIVED — headers recebidos"); break;
    case 3: console.log("LOADING — corpo chegando"); break;
    case 4: console.log("DONE — tudo concluído"); break;
  }
};

xhr.open("GET", "https://jsonplaceholder.typicode.com/posts/1");
xhr.send();

Na prática, você só se importava com o estado 4. Mas a API te expunha tudo isso de qualquer forma.

POST com XMLHttpRequest

Enviar dados para o servidor era ainda mais trabalhoso:

var xhr = new XMLHttpRequest();
xhr.open("POST", "https://jsonplaceholder.typicode.com/posts", true);

// Precisa setar o header manualmente
xhr.setRequestHeader("Content-Type", "application/json");

xhr.onreadystatechange = function() {
  if (xhr.readyState === 4 && xhr.status === 201) {
    var resposta = JSON.parse(xhr.responseText);
    console.log("Post criado com id:", resposta.id);
  }
};

// Precisa serializar manualmente
xhr.send(JSON.stringify({
  title: "Meu post",
  body: "Conteúdo do post",
  userId: 1
}));

jQuery.ajax() — a salvação dos anos 2000

O jQuery chegou em 2006 e abstraiu toda essa verbosidade. Por anos, foi a forma dominante de fazer requisições:

// jQuery — muito mais simples que o XHR puro
$.ajax({
  url: "https://jsonplaceholder.typicode.com/users/1",
  method: "GET",
  dataType: "json",
  success: function(usuario) {
    console.log("Nome:", usuario.name);
  },
  error: function(xhr, status, erro) {
    console.error("Erro:", erro);
  }
});

// Versão mais curta
$.getJSON("https://jsonplaceholder.typicode.com/users/1", function(usuario) {
  console.log(usuario.name);
});

// POST com jQuery
$.post("https://jsonplaceholder.typicode.com/posts",
  { title: "Meu post", userId: 1 },
  function(resposta) {
    console.log("Criado:", resposta.id);
  }
);

O jQuery resolveu a verbosidade mas ainda era baseado em callbacks — e quando você precisava encadear múltiplas requisições, o Callback Hell voltava.

O Callback Hell do AJAX

Qualquer fluxo com múltiplas requisições dependentes virava isso:

// Autenticação → perfil → permissões → dashboard
// Tudo com jQuery AJAX aninhado

$.ajax({
  url: "/api/login",
  method: "POST",
  data: { email: email, senha: senha },
  success: function(auth) {

    $.ajax({
      url: "/api/perfil/" + auth.userId,
      headers: { "Authorization": "Bearer " + auth.token },
      success: function(perfil) {

        $.ajax({
          url: "/api/permissoes/" + perfil.cargo,
          success: function(permissoes) {

            $.ajax({
              url: "/api/dashboard",
              success: function(dashboard) {
                // finalmente chegamos aqui
                renderizarApp(perfil, permissoes, dashboard);
              },
              error: function() { mostrarErro("Erro ao carregar dashboard"); }
            });

          },
          error: function() { mostrarErro("Erro ao carregar permissões"); }
        });

      },
      error: function() { mostrarErro("Erro ao carregar perfil"); }
    });

  },
  error: function() { mostrarErro("Login inválido"); }
});

Insuportável. E esse era o padrão real de aplicações complexas nos anos 2000 e início dos 2010.

jQuery Deferred — a proto-Promise (2010)

O jQuery tentou resolver isso com os objetos Deferred, que eram uma espécie de Promise antes das Promises existirem:

function buscarUsuario(id) {
  return $.getJSON("/api/usuario/" + id); // retorna um Deferred
}

function buscarPedidos(usuarioId) {
  return $.getJSON("/api/pedidos/" + usuarioId);
}

// Encadeamento — muito melhor que callbacks aninhados
buscarUsuario(1)
  .then(function(usuario) {
    console.log("Usuário:", usuario.nome);
    return buscarPedidos(usuario.id);
  })
  .then(function(pedidos) {
    console.log("Pedidos:", pedidos.length);
  })
  .fail(function(erro) {
    console.error("Erro:", erro);
  });

// Em paralelo
$.when(buscarUsuario(1), buscarPedidos(1))
  .done(function(usuario, pedidos) {
    console.log(usuario[0].nome, pedidos[0].length);
  });

Era melhor — mas a API do jQuery Deferred tinha inconsistências e não era compatível com as Promises nativas que vieram depois.

A linha do tempo completa

1999 — Microsoft cria XMLHTTP no IE5
2004 — Gmail usa AJAX extensivamente pela primeira vez
2005 — Google Maps. Jesse James Garrett cunha o termo "AJAX"
2006 — jQuery 1.0 — $.ajax() simplifica tudo
2006 — W3C publica o primeiro rascunho do XMLHttpRequest
2010 — jQuery Deferred — proto-Promises
2012 — Promises/A+ — especificação da comunidade
2015 — ES6 traz a Promise nativa para a LINGUAGEM
2015 — Fetch é publicado — não pelo ECMAScript, e sim pelo WHATWG:
       é API do navegador, não da linguagem (só chegou ao Node em 2022)
2017 — ES2017: async/await
2022 — XMLHttpRequest ainda existe, mas é considerado legado

A comparação direta — mesma operação, três eras

Buscar um usuário e seus posts, tratar erros:

// ── ERA 1: XMLHttpRequest puro (1999–2006) ──────────
var xhr = new XMLHttpRequest();
xhr.open("GET", "https://jsonplaceholder.typicode.com/users/1", true);
xhr.onreadystatechange = function() {
  if (xhr.readyState === 4) {
    if (xhr.status === 200) {
      var usuario = JSON.parse(xhr.responseText);

      var xhr2 = new XMLHttpRequest();
      xhr2.open("GET",
        "https://jsonplaceholder.typicode.com/posts?userId=" + usuario.id,
        true
      );
      xhr2.onreadystatechange = function() {
        if (xhr2.readyState === 4) {
          if (xhr2.status === 200) {
            var posts = JSON.parse(xhr2.responseText);
            console.log(usuario.name + " tem " + posts.length + " posts");
          } else {
            console.error("Erro ao buscar posts:", xhr2.status);
          }
        }
      };
      xhr2.send();

    } else {
      console.error("Erro ao buscar usuário:", xhr.status);
    }
  }
};
xhr.send();


// ── ERA 2: jQuery.ajax (2006–2015) ──────────────────
$.getJSON("https://jsonplaceholder.typicode.com/users/1")
  .then(function(usuario) {
    return $.getJSON(
      "https://jsonplaceholder.typicode.com/posts?userId=" + usuario.id
    ).then(function(posts) {
      return { usuario: usuario, posts: posts };
    });
  })
  .done(function(dados) {
    console.log(dados.usuario.name + " tem " + dados.posts.length + " posts");
  })
  .fail(function(erro) {
    console.error("Erro:", erro.statusText);
  });


// ── ERA 3: Fetch + async/await (2017–hoje) ──────────
async function carregarDados() {
  try {
    const resUsuario = await fetch(
      "https://jsonplaceholder.typicode.com/users/1"
    );
    if (!resUsuario.ok) throw new Error(`Erro ${resUsuario.status}`);
    const usuario = await resUsuario.json();

    const resPosts = await fetch(
      `https://jsonplaceholder.typicode.com/posts?userId=${usuario.id}`
    );
    if (!resPosts.ok) throw new Error(`Erro ${resPosts.status}`);
    const posts = await resPosts.json();

    console.log(`${usuario.name} tem ${posts.length} posts`);
  } catch (erro) {
    console.error("Erro:", erro.message);
  }
}

carregarDados();

A progressão é clara: de 30 linhas aninhadas e confusas para 15 linhas lineares e legíveis.

Quando você ainda vai ver XHR

O XHR ainda tem um caso de uso relevante hoje: monitorar o progresso de upload de arquivos — algo que o Fetch ainda não suporta nativamente:

function uploadComProgresso(arquivo, url) {
  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    const formData = new FormData();
    formData.append("arquivo", arquivo);

    // Este evento SÓ existe no XHR — Fetch não tem equivalente ainda
    xhr.upload.addEventListener("progress", (evento) => {
      if (evento.lengthComputable) {
        const porcentagem = Math.round((evento.loaded / evento.total) * 100);
        console.log(`Upload: ${porcentagem}%`);
        atualizarBarraProgresso(porcentagem);
      }
    });

    xhr.addEventListener("load", () => {
      if (xhr.status >= 200 && xhr.status < 300) {
        resolve(JSON.parse(xhr.responseText));
      } else {
        reject(new Error(`Erro ${xhr.status}`));
      }
    });

    xhr.addEventListener("error", () => reject(new Error("Falha de rede")));
    xhr.addEventListener("abort", () => reject(new Error("Upload cancelado")));

    xhr.open("POST", url);
    xhr.send(formData);
  });
}

// Usando
const input = document.querySelector("#arquivo");
input.addEventListener("change", async () => {
  try {
    const resultado = await uploadComProgresso(input.files[0], "/api/upload");
    console.log("Upload concluído:", resultado);
  } catch (erro) {
    console.error(erro.message);
  }
});

Note que mesmo usando XHR, nós o envolvemos em uma Promise — aproveitando o melhor dos dois mundos.

O futuro: Fetch com progresso

A API de Streams já permite monitorar o progresso de download com Fetch, e o progresso de upload está em desenvolvimento na especificação:

// Progresso de DOWNLOAD com Fetch (já possível hoje)
async function downloadComProgresso(url) {
  const response = await fetch(url);
  const tamanhoTotal = Number(response.headers.get("Content-Length"));
  let recebido = 0;

  const reader = response.body.getReader();
  const chunks = [];

  while (true) {
    const { done, value } = await reader.read();
    if (done) break;

    chunks.push(value);
    recebido += value.length;

    const porcentagem = Math.round((recebido / tamanhoTotal) * 100);
    console.log(`Download: ${porcentagem}%`);
  }

  // Monta o resultado final
  const totalBytes = chunks.reduce((acc, chunk) => acc + chunk.length, 0);
  const resultado = new Uint8Array(totalBytes);
  let offset = 0;
  for (const chunk of chunks) {
    resultado.set(chunk, offset);
    offset += chunk.length;
  }

  return resultado;
}

Resumo da evolução

Característica XHR Puro jQuery.ajax Fetch + async/await
Verbosidade Alta Média Baixa
Baseado em Callbacks Callbacks / Deferred Promises nativas
Tratamento de erro Manual e verboso .fail() try/catch
Legibilidade Baixa Média Alta
Sem dependência ❌ (jQuery)
Cancelamento xhr.abort() xhr.abort() AbortController
Progresso de upload ⚠️ ainda limitado
Streams
Status em erros HTTP Manual Manual Manual (response.ok)

Quinze anos separam o XMLHttpRequest do fetch, e o que mudou nesse intervalo não foi a capacidade — requisição assíncrona o XHR sempre fez — e sim o vocabulário disponível para expressá-la. Os cinco estados conferidos à mão viraram uma Promise, e o $.ajax do jQuery foi o intermediário que experimentou a forma que a plataforma acabaria adotando. O XHR, aliás, não morreu: ainda é o único caminho para acompanhar o progresso de um upload.

Fontes e Referências

Exercícios

Exercício 1

Este XHR verifica readyState === 4 mas não olha o status. O que o usuário vê quando o servidor responde 404 com uma página de erro em HTML?

var xhr = new XMLHttpRequest();
xhr.open("GET", "/api/usuario/999", true);

xhr.onreadystatechange = function () {
  if (xhr.readyState === 4) {
    var usuario = JSON.parse(xhr.responseText);
    document.querySelector("#nome").textContent = usuario.nome;
  }
};

xhr.send();
Ver resposta

✓ Resposta: O JSON.parse estoura com SyntaxError: Unexpected token '<', o handler morre no meio e o nome nunca é preenchido — a tela fica parada, sem mensagem nenhuma. O readyState === 4 significa apenas que a resposta terminou de chegar, não que ela foi bem-sucedida: um 404, um 500 e um 200 chegam todos ao estado 4. É por isso que o padrão correto da época era testar as duas coisas, xhr.readyState === 4 && xhr.status === 200. Repare que esse é exatamente o mesmo descuido que reaparece hoje com o fetch, na forma de esquecer o response.ok — a ferramenta mudou, a armadilha continua a mesma, porque em ambas o erro HTTP é uma resposta e não uma falha. E note o detalhe do sintoma: o < da mensagem é o primeiro caractere do <!DOCTYPE html> da página de erro, uma pista que aponta para o parser quando a causa está na URL.

Exercício 2

Nas duas eras, o que acontece com o segundo pedido quando o primeiro falha?

// ERA 1 — XHR aninhado
xhr.onreadystatechange = function () {
  if (xhr.readyState === 4 && xhr.status === 200) {
    var usuario = JSON.parse(xhr.responseText);
    // ... aqui dentro é criado o xhr2
  } else {
    console.error("Erro ao buscar usuário");
  }
};

// ERA 3 — async/await
const usuario = await (await fetch(url1)).json();
const posts = await (await fetch(url2 + usuario.id)).json();
Ver resposta

✓ Resposta: Na ERA 1 o segundo pedido simplesmente não acontece, porque ele só existe dentro do ramo de sucesso — e essa é a parte boa. A parte ruim é o que o tratamento de erro custava: cada nível precisava do próprio else, com a própria mensagem, e um nível esquecido virava falha silenciosa. Na ERA 3 o segundo await nunca é alcançado, pelo mesmo motivo pelo qual uma linha depois de um throw não executa: a rejeição interrompe a função ali. A diferença é que o tratamento acontece em um lugar só, no try/catch que envolve tudo, em vez de espalhado por cada nível. Vale reparar num defeito que o trecho da ERA 3 tem: (await fetch(url)).json() pula a checagem de response.ok, e por isso comete exatamente o erro do exercício anterior — a sintaxe ficou moderna, o descuido permaneceu.

Exercício 3

O servidor envia a resposta com Transfer-Encoding: chunked e portanto sem Content-Length. O que a barra de progresso mostra?

const response = await fetch(url);
const tamanhoTotal = Number(response.headers.get("Content-Length"));
let recebido = 0;

// ... dentro do laço de leitura:
recebido += value.length;
const porcentagem = Math.round((recebido / tamanhoTotal) * 100);
console.log(`Download: ${porcentagem}%`);
Ver resposta

✓ Resposta: Mostra Download: Infinity% a cada pedaço recebido. O headers.get devolve null quando o cabeçalho não existe, e Number(null) é 0 — não NaN, o que seria menos traiçoeiro, porque NaN ao menos apareceria como "NaN%" e denunciaria o problema. Dividir por zero em JavaScript não lança erro: produz Infinity, que atravessa o Math.round intacto. E a ausência de Content-Length não é caso raro: acontece sempre que a resposta é gerada em fluxo, e também quando há compressão, situação em que o cabeçalho pode informar o tamanho comprimido enquanto o leitor entrega bytes já descomprimidos — aí a barra passa de 100%. O tratamento correto é verificar antes se o total é conhecido e maior que zero, e, quando não for, mostrar um indicador indeterminado em vez de uma porcentagem inventada.

Exercício 4

Por que este upload precisa de XMLHttpRequest, se o fetch é a API moderna? E por que ele é embrulhado numa Promise?

xhr.upload.addEventListener("progress", (evento) => {
  if (evento.lengthComputable) {
    atualizarBarra(Math.round((evento.loaded / evento.total) * 100));
  }
});
Ver resposta

✓ Resposta: Porque o fetch não expõe o progresso de envio. Ele consegue relatar o progresso de download, lendo response.body como fluxo, mas o corpo que sai do navegador não tem um evento equivalente amplamente disponível — o XHR tem, no objeto xhr.upload, e é o único motivo pelo qual essa API de 1999 continua em uso em código novo. O lengthComputable aparece porque o tamanho nem sempre é conhecido de antemão, o mesmo problema do exercício anterior visto do outro lado. Quanto ao embrulho: XHR é baseado em eventos, e eventos não se encaixam em await nem em try/catch. Envolver o objeto num new Promise — resolvendo no load, rejeitando no error e no abort — traduz a API antiga para o vocabulário atual, e é o padrão geral para adaptar qualquer API de callback ao mundo das Promises. O nome disso é promisify, e o Node traz até um utilitário pronto, o util.promisify.

Exercício 5

A linha do tempo do artigo separa duas coisas que costumam ser confundidas. A Promise e o fetch vieram do mesmo lugar? O que isso explica na prática?

// Node.js 16, em 2021:
Promise.resolve(1).then(v => console.log(v)); // funciona
fetch("https://exemplo.com");                 // ReferenceError: fetch is not defined
Ver resposta

✓ Resposta: Não vieram. A Promise faz parte da linguagem, definida pelo ECMAScript e presente em qualquer lugar onde haja um motor JavaScript — navegador, Node, Deno, um videogame. O fetch é uma API de ambiente, especificada pelo WHATWG junto com o resto da plataforma web, e existe apenas onde alguém decidiu implementá-la. Daí o erro do exemplo: o Node só passou a trazer fetch embutido na versão 18, em 2022, e é por isso que projetos anteriores dependiam de pacotes como node-fetch ou axios. A mesma divisão explica outras estranhezas do dia a dia: setTimeout, localStorage, document e console também não pertencem à linguagem, o que é a razão de document não existir no Node e de require não existir no navegador. Saber de qual lado da fronteira está cada coisa poupa muito tempo diante de um ReferenceError.

Comentários

Mais em Javascript

Segurança em aplicações web
Segurança em aplicações web

Segurança não é etapa final, é decisão em cada linha. O artigo percorre as…

Trabalhando com datas e horas em JavaScript
Trabalhando com datas e horas em JavaScript

Data parece o tipo mais simples que existe e é o que mais engana: o mês começa…

Revisão + Projeto Final: Produção Real
Revisão + Projeto Final: Produção Real

Deploy, segurança e performance só existem depois que a aplicação existe, e…