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

Estado global com Zustand e React Query
Estado global com Zustand e React Query

Tema, carrinho e filtro são seus; a lista de produtos é uma cópia que…

Eventos: click, input, submit e muito mais
Eventos: click, input, submit e muito mais

Ler e escrever no DOM é metade do trabalho; a outra metade é reagir. Aqui…

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…