Pular para o conteúdo
Cursos

DevClub

LógicaFront-endBack-endMobile

IA Club

IA na prática
Estudar programaçãoEstudar IA
ComparaçãoIntermediáriocódigo testado

Axios ou fetch: consumir API no Node e no navegador

O que o axios faz que o fetch não faz, o que virou dispensável no Node 24, e os mesmos seis casos escritos nas duas bibliotecas.

Rodolfo Mori15 min de leitura

fetch e axios são clientes HTTP: ferramentas usadas pelo seu código para enviar uma requisição e receber a resposta de uma API. O fetch já vem no navegador e nas versões atuais do Node; axios é uma dependência instalada no projeto e acrescenta conveniências como interceptores, rejeição automática para status de erro e progresso de upload.

Pense em duas formas de enviar o mesmo pacote. Com fetch, você fala direto com a transportadora e confere por conta própria o protocolo, o conteúdo e os problemas da entrega. Com axios, há uma central que abre o pacote de resposta, aplica regras antes e depois da viagem e transforma certos status em erro. A estrada é a mesma: tecnicamente, ambos fazem HTTP. A diferença está no trabalho que cada API assume ao redor da requisição.

Para não discutir no abstrato, este artigo roda as duas bibliotecas contra a mesma API: a clínica veterinária Pata Amiga, um servidor Express de verdade ouvindo em localhost:4477, com rotas que respondem 200, 201, 400, 401, 404, 503, uma rota que demora três segundos de propósito e uma rota que devolve HTML onde você esperava JSON. Cada saída que você vê abaixo saiu do terminal, no Node 24.16.0 com axios 1.19.0 e Express 5.2.1.

O fetch que já vem no Node 24 e no navegador

fetch é uma função global. Não tem import, não tem npm install, e o mesmo código roda nos dois lados:

js
console.log('fetch               ->', typeof fetch);
console.log('Headers             ->', typeof Headers);
console.log('FormData            ->', typeof FormData);
console.log('AbortSignal.timeout ->', typeof AbortSignal.timeout);
console.log('Node                ->', process.version);
fetch -> function Headers -> function FormData -> function AbortSignal.timeout -> function Node -> v24.16.0

Isso é novo o suficiente para ainda confundir muita gente. O fetch entrou como global no Node 18, deixou de ser experimental no Node 21 e, no Node 24, roda sem imprimir nenhum aviso no terminal. Se o seu projeto ainda tem node-fetch no package.json e o Node é 20 ou mais novo, você está instalando um pacote para ter algo que já está lá.

A diferença de forma entre os dois aparece na primeira linha de código. O fetch entrega a resposta; o corpo vem numa segunda etapa, porque ele pode ser um JSON, um texto, um blob ou um stream de vários megabytes que você talvez nem queira carregar na memória:

js
import axios from 'axios';

// fetch: duas etapas — a resposta chega, o corpo vem depois
const resposta = await fetch('http://localhost:4477/pacientes/17');
const comFetch = await resposta.json();
console.log('fetch   ->', comFetch);

// axios: uma etapa — o corpo já vem em .data, com o JSON convertido
const { data: comAxios } = await axios.get('http://localhost:4477/pacientes/17');
console.log('axios   ->', comAxios);

console.log('chaves do retorno do axios:', Object.keys(await axios.get('http://localhost:4477/pacientes/17')));
fetch -> { id: 17, nome: 'Amora', especie: 'gato', tutor: 'Marina Alves', peso: 4.2 } axios -> { id: 17, nome: 'Amora', especie: 'gato', tutor: 'Marina Alves', peso: 4.2 } chaves do retorno do axios: [ 'status', 'statusText', 'headers', 'config', 'request', 'data' ]

O objeto do paciente é idêntico. O que muda é o caminho até ele:

fetch fetch(url) Response status, ok, headers .json() objeto axios axios.get(url) resposta status, headers, data .data objeto

O axios devolve um envelope com seis chaves e o corpo já convertido dentro de data. É uma etapa a menos por chamada — e, multiplicado por cinquenta chamadas num projeto, é o argumento honesto de quem prefere o axios por ergonomia.

JSON, erro HTTP e timeout: os três atritos do fetch puro

Se o fetch cobre tudo, por que tanta gente ainda instala o axios? Porque o fetch tem três comportamentos que só mordem em produção. Vale ver os três rodando.

O 404 não cai no catch

Esta é a armadilha número um. O paciente 99 não existe na clínica. O código abaixo tem try/catch e mesmo assim segue em frente:

js
try {
  const resposta = await fetch('http://localhost:4477/pacientes/99');
  const paciente = await resposta.json();
  console.log('nome do paciente:', paciente.nome);
} catch (erro) {
  console.log('caiu no catch:', erro.message);
}
nome do paciente: undefined

O catch nunca rodou. O servidor respondeu 404 com o corpo { erro: 'paciente_nao_encontrado' }, o fetch considerou isso uma requisição bem-sucedida — porque, do ponto de vista da rede, foi — e o .json() converteu o corpo do erro num objeto que não tem nome. Daí o undefined que segue vivo pela aplicação até estourar três funções adiante.

O axios rejeita a promessa em qualquer status fora da faixa 2xx, e ainda anexa a resposta do servidor ao erro:

js
import axios from 'axios';

try {
  const resposta = await axios.get('http://localhost:4477/pacientes/99');
  console.log('nome do paciente:', resposta.data.nome);
} catch (erro) {
  console.log('caiu no catch:', erro.message);
  console.log('status:', erro.response.status);
  console.log('corpo do erro:', erro.response.data);
}
caiu no catch: Request failed with status code 404 status: 404 corpo do erro: { erro: 'paciente_nao_encontrado', id: 99 }

Nem toda resposta 200 é JSON

A rota /status da clínica responde 200, mas devolve HTML. Chamar .json() nela produz o erro mais famoso de quem consome API:

js
import axios from 'axios';

const { data, status } = await axios.get('http://localhost:4477/status');
console.log('axios  ', status, typeof data, JSON.stringify(data));

const resposta = await fetch('http://localhost:4477/status');
console.log('fetch  ', resposta.status, resposta.headers.get('content-type'));
await resposta.json();
axios 200 string "<h1>Clinica Pata Amiga: no ar</h1>" fetch 200 text/html; charset=utf-8 <anonymous_script>:1 <h1>Clinica Pata Amiga: no ar</h1> ^

SyntaxError: Unexpected token ‘<’, “<h1>Clinic”… is not valid JSON at JSON.parse (<anonymous>) at parseJSONFromBytes (node:internal/deps/undici/undici:4337:19) at successSteps (node:internal/deps/undici/undici:6985:27) at readAllBytes (node:internal/deps/undici/undici:5908:13) at process.processTicksAndRejections (node:internal/process/task_queues:104:5)

Node.js v24.16.0

Esse SyntaxError: Unexpected token é quase sempre isso: você pediu JSON e recebeu uma página de erro do proxy, do Nginx ou do próprio framework. O token inesperado é o sinal de menor que abre a tag h1, a primeira letra do HTML que chegou no lugar do objeto. O axios não explode — ele tenta converter, não consegue, e devolve a string crua em data. É mais gentil, e também mais silencioso: seu código segue achando que tem um objeto.

Sem AbortSignal, o fetch espera para sempre

A rota /vacinas demora três segundos. O fetch não tem opção de timeout; o corte vem de um AbortSignal. O axios tem uma opção e pronto:

js
import axios from 'axios';

const t1 = performance.now();
try {
  await fetch('http://localhost:4477/vacinas', { signal: AbortSignal.timeout(800) });
} catch (erro) {
  console.log('fetch  ', erro.name, '|', erro.message, '|', Math.round(performance.now() - t1), 'ms');
}

const t2 = performance.now();
try {
  await axios.get('http://localhost:4477/vacinas', { timeout: 800 });
} catch (erro) {
  console.log('axios  ', erro.code, '|', erro.message, '|', Math.round(performance.now() - t2), 'ms');
}
fetch TimeoutError | The operation was aborted due to timeout | 810 ms axios ECONNABORTED | timeout of 800ms exceeded | 815 ms

Os dois cortaram no tempo certo. AbortSignal.timeout() não é nenhum bicho de sete cabeças — mas é uma linha que você precisa lembrar de escrever em toda chamada, e é justamente esse tipo de repetição que empurra times para o axios.

Os mesmos seis casos nas duas bibliotecas

Este é o resumo do que muda no dia a dia. Nenhuma linha aqui é opinião: é o que cada biblioteca exige para o mesmo resultado.

caso com fetch com axios
GET com JSON (await fetch(url)).json() (await axios.get(url)).data
POST com JSON method, Content-Type e JSON.stringify na mão objeto direto no segundo argumento
erro HTTP if (!resposta.ok) throw escrito por você rejeita sozinho, com erro.response
resposta que não é JSON SyntaxError estourando devolve a string em data, sem erro
timeout signal: AbortSignal.timeout(ms) timeout: ms
header fixo em toda chamada um wrapper seu axios.create({ headers })

O caso do POST merece o código, porque ele guarda o esquecimento mais comum de quem está saindo do fetch de tutorial para o fetch de projeto:

js
import axios from 'axios';

// fetch: você monta o header e serializa o corpo na mão
const r1 = await fetch('http://localhost:4477/consultas', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ pacienteId: 17, motivo: 'vacina anual' }),
});
console.log('fetch  ', r1.status, await r1.json());

// axios: o objeto vai direto; o header e o JSON.stringify são dele
const r2 = await axios.post('http://localhost:4477/consultas', {
  pacienteId: 18,
  motivo: 'corte de unha',
});
console.log('axios  ', r2.status, r2.data);

// o esquecimento clássico: POST com fetch sem o Content-Type
const r3 = await fetch('http://localhost:4477/consultas', {
  method: 'POST',
  body: JSON.stringify({ pacienteId: 17, motivo: 'retorno' }),
});
console.log('sem header', r3.status, await r3.json());
fetch 201 { id: 903, pacienteId: 17, status: 'agendada' } axios 201 { id: 903, pacienteId: 18, status: 'agendada' } sem header 400 { erro: 'pacienteId_obrigatorio' }

Repare na terceira linha. O corpo foi enviado, o JSON estava perfeito, e mesmo assim a clínica respondeu 400 dizendo que faltou pacienteId. Faltou o Content-Type: application/json: sem ele, o express.json() não processa o corpo e req.body chega vazio. Esse é o bug que consome uma tarde inteira, porque o erro aponta para o campo e não para o header. O axios manda o header sozinho quando você passa um objeto, e por isso a classe de bug simplesmente não existe lá.

O último caso da tabela é o header fixo. Com fetch, ele viaja em toda chamada; com axios, você configura uma instância uma vez:

js
import axios from 'axios';

// fetch: o header vai na mão, em toda chamada
const r1 = await fetch('http://localhost:4477/prontuarios/17', {
  headers: { Authorization: 'Bearer chave-da-clinica' },
});
console.log('fetch com token   ', r1.status, await r1.json());

const r2 = await fetch('http://localhost:4477/prontuarios/17');
console.log('fetch sem token   ', r2.status, await r2.json());

// axios: instância com o header configurado uma vez
const api = axios.create({
  baseURL: 'http://localhost:4477',
  headers: { Authorization: 'Bearer chave-da-clinica' },
  timeout: 5000,
});

const r3 = await api.get('/prontuarios/17');
console.log('axios instância   ', r3.status, r3.data);
fetch com token 200 { id: 17, anotacoes: 'Retorno em 15 dias.' } fetch sem token 401 { erro: 'sem_token' } axios instância 200 { id: 17, anotacoes: 'Retorno em 15 dias.' }

Se você já leu sobre autenticação com JWT no Express, esse Authorization é exatamente o token que o login devolve.

Interceptor: o recurso que ainda justifica o axios

Aqui está o recurso para o qual o fetch não tem equivalente direto. Um interceptor é um gancho que roda em toda requisição ou resposta da instância, inclusive nas chamadas que já estão escritas em outros arquivos e que você não vai tocar.

O caso clássico: o token venceu, a API respondeu 401, e você quer renovar o token e repetir a chamada original sem que quem chamou perceba nada.

js
import axios from 'axios';

let token = 'token-vencido';

const api = axios.create({ baseURL: 'http://localhost:4477' });

// interceptor de requisição: carimba o token atual em toda chamada
api.interceptors.request.use((config) => {
  config.headers.Authorization = `Bearer ${token}`;
  console.log('  -> saindo:', config.method.toUpperCase(), config.url);
  return config;
});

// interceptor de resposta: no 401, renova o token e repete a chamada original
api.interceptors.response.use(
  (resposta) => resposta,
  async (erro) => {
    if (erro.response?.status === 401 && !erro.config._jaTentou) {
      console.log('  <- 401: renovando o token');
      const { data } = await axios.post('http://localhost:4477/login');
      token = data.token;
      erro.config._jaTentou = true;
      return api.request(erro.config);
    }
    return Promise.reject(erro);
  },
);

// quem chama não sabe de nada disso
const { data } = await api.get('/prontuarios/17');
console.log('prontuário:', data);
-> saindo: GET /prontuarios/17 <- 401: renovando o token -> saindo: GET /prontuarios/17 prontuário: { id: 17, anotacoes: 'Retorno em 15 dias.' }

A chamada saiu, tomou 401, o interceptor renovou o token, a chamada saiu de novo e o await api.get() devolveu o prontuário. A última linha do arquivo não sabe que isso aconteceu.

Dá para fazer o mesmo com fetch? Dá — desde que todo mundo passe pela sua função. É o preço do interceptor caseiro: ele não é um gancho, é uma porta, e funciona só enquanto ninguém chama fetch direto. Este é o cliente da clínica, com timeout, baseURL, JSON automático, erro tipado e renovação de token:

js
const BASE = 'http://localhost:4477';
let token = 'token-vencido';

class ErroDeApi extends Error {
  constructor(status, corpo) {
    super(`A clínica respondeu ${status}`);
    this.name = 'ErroDeApi';
    this.status = status;
    this.corpo = corpo;
  }
}

async function api(caminho, opcoes = {}, jaTentou = false) {
  const resposta = await fetch(BASE + caminho, {
    ...opcoes,
    signal: AbortSignal.timeout(opcoes.timeout ?? 5000),
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${token}`,
      ...opcoes.headers,
    },
    body: opcoes.json ? JSON.stringify(opcoes.json) : opcoes.body,
  });

  if (resposta.status === 401 && !jaTentou) {
    const login = await fetch(`${BASE}/login`, { method: 'POST' });
    token = (await login.json()).token;
    return api(caminho, opcoes, true);
  }

  const tipo = resposta.headers.get('content-type') ?? '';
  const corpo = tipo.includes('application/json') ? await resposta.json() : await resposta.text();

  if (!resposta.ok) throw new ErroDeApi(resposta.status, corpo);
  return corpo;
}

console.log('prontuário:', await api('/prontuarios/17'));
console.log('consulta  :', await api('/consultas', { method: 'POST', json: { pacienteId: 17 } }));
console.log('status    :', await api('/status'));

try {
  await api('/pacientes/99');
} catch (erro) {
  console.log('erro      :', erro.name, erro.status, erro.corpo);
}
prontuário: { id: 17, anotacoes: 'Retorno em 15 dias.' } consulta : { id: 903, pacienteId: 17, status: 'agendada' } status : <h1>Clinica Pata Amiga: no ar</h1> erro : ErroDeApi 404 { erro: 'paciente_nao_encontrado', id: 99 }

Contando só linhas não vazias, o cliente com axios (instância mais dois interceptores) fica em 19 linhas; o cliente com fetch que faz a mesma coisa fica em 31 linhas. Doze linhas de diferença, escritas uma vez por projeto. Se essa é a sua régua, o axios não se paga; se o seu projeto tem cinco clientes de API diferentes e uma política de erro que muda toda semana, ele se paga no primeiro mês.

Cancelamento, retry e upload com progresso

Cancelar é empate: os dois falam AbortController. A cena é a mesma de sempre — o tutor digitou o nome do bicho, a busca saiu, e ele digitou de novo antes de a primeira resposta chegar.

js
import axios from 'axios';

const controle = new AbortController();
setTimeout(() => controle.abort(), 300);

try {
  await fetch('http://localhost:4477/vacinas', { signal: controle.signal });
} catch (erro) {
  console.log('fetch:', erro.name, '|', erro.message);
}

const controle2 = new AbortController();
setTimeout(() => controle2.abort(), 300);

try {
  await axios.get('http://localhost:4477/vacinas', { signal: controle2.signal });
} catch (erro) {
  console.log('axios:', erro.code, '|', erro.message, '| é cancelamento?', axios.isCancel(erro));
}
fetch: AbortError | This operation was aborted axios: ERR_CANCELED | canceled | é cancelamento? true

A vantagem miúda do axios é o axios.isCancel(): você distingue “cancelei de propósito” de “deu erro” sem comparar string de mensagem. Com fetch, a verificação é erro.name === 'AbortError'.

Já o retry expõe o atrito do fetch de novo. A rota /laboratorio/exames responde 503 nas duas primeiras chamadas e só devolve o hemograma na terceira. Um laço de retry comum não funciona com fetch cru:

js
const espera = (ms) => new Promise((r) => setTimeout(r, ms));

async function comRetry(chamada, tentativas = 4) {
  for (let n = 1; n <= tentativas; n++) {
    try {
      return await chamada();
    } catch (erro) {
      if (n === tentativas) throw erro;
      const atraso = 200 * 2 ** (n - 1);
      console.log(`  tentativa ${n} falhou; esperando ${atraso}ms`);
      await espera(atraso);
    }
  }
}

// fetch cru: o 503 não vira exceção, então o retry nunca acontece
const resposta = await comRetry(() => fetch('http://localhost:4477/laboratorio/exames?corrida=cru'));
console.log('fetch cru      ->', resposta.status, await resposta.json());

// com um empurrãozinho: transformar o status ruim em erro
async function pedir(url) {
  const r = await fetch(url);
  if (!r.ok) throw new Error(`HTTP ${r.status}`);
  return r.json();
}

const dados = await comRetry(() => pedir('http://localhost:4477/laboratorio/exames?corrida=ok'));
console.log('fetch + throw  ->', dados);
fetch cru -> 503 { erro: 'lab_indisponivel', tentativa: 1 } tentativa 1 falhou; esperando 200ms tentativa 2 falhou; esperando 400ms fetch + throw -> { tentativa: 3, hemograma: 'normal' }

A primeira linha é o bug em estado puro: a função de retry rodou, não imprimiu nenhuma tentativa porque não viu exceção nenhuma, e devolveu o 503 de cara. Só a segunda chamada, com o throw manual, insistiu até o laboratório responder. Com axios, o mesmo laço funciona sem nenhuma adaptação, porque o 503 já chega como exceção:

js
import axios from 'axios';

const api = axios.create({ baseURL: 'http://localhost:4477' });
const t = performance.now();
const { data } = await comRetry(() => api.get('/laboratorio/exames?corrida=axios'));
console.log('axios ->', data, '|', Math.round(performance.now() - t), 'ms');
tentativa 1 falhou; esperando 200ms tentativa 2 falhou; esperando 400ms axios -> { tentativa: 3, hemograma: 'normal' } | 618 ms

Os 618 ms são os 200 ms mais os 400 ms de espera exponencial, mais o tempo das três requisições. Esse é o comportamento que você quer numa integração com laboratório externo: insistir com intervalo crescente, e não em rajada.

O upload com progresso é o último ponto, e nele o fetch simplesmente não compete. Não existe evento de progresso de envio na especificação; a alternativa é mandar o corpo como ReadableStream com duplex: 'half', o que exige suporte do servidor e HTTP/2. Com axios são três linhas:

js
import axios from 'axios';

// 8 MB fingindo ser o raio-X do Tobias
const raioX = Buffer.alloc(8 * 1024 * 1024, 7);

const { data } = await axios.post('http://localhost:4477/exames/upload', raioX, {
  headers: { 'Content-Type': 'application/octet-stream' },
  onUploadProgress: (evento) => {
    const pct = Math.round((evento.loaded / evento.total) * 100);
    console.log(`enviado ${pct}% (${evento.loaded} de ${evento.total} bytes)`);
  },
});

console.log('servidor recebeu:', data.recebido, 'bytes');
enviado 1% (65536 de 8388608 bytes) enviado 100% (8388608 de 8388608 bytes) servidor recebeu: 8388608 bytes

Só dois eventos, e por um motivo interessante: em localhost o buffer do socket esvazia praticamente de uma vez, então não há progresso intermediário para relatar. Numa rede de verdade, com o tutor mandando o raio-X pelo 4G, esses mesmos eventos chegam dezenas de vezes e a barra anda de fato.

Quando a rede cai: o erro que os dois escondem

Até aqui o servidor sempre respondeu alguma coisa. Falta o caso em que ele não responde nada — servidor fora do ar, Wi-Fi que caiu, porta errada no .env. Aqui, a porta 4488 não tem ninguém ouvindo:

js
import axios from 'axios';

try {
  await fetch('http://localhost:4488/pacientes/17');
} catch (erro) {
  console.log('fetch:', erro.name, '|', erro.message);
  console.log('  causa:', erro.cause?.code, erro.cause?.message);
}

try {
  await axios.get('http://localhost:4488/pacientes/17');
} catch (erro) {
  console.log('axios:', erro.code, '|', erro.message);
  console.log('  tem erro.response?', erro.response !== undefined);
}
fetch: TypeError | fetch failed causa: ECONNREFUSED axios: ECONNREFUSED | connect ECONNREFUSED ::1:4488; connect ECONNREFUSED 127.0.0.1:4488 tem erro.response? false

Duas coisas para guardar. A primeira: TypeError: fetch failed é a mensagem mais inútil do Node, e o motivo real mora em erro.cause. Sempre que esse erro aparecer no seu log, imprima a causa junto — é a diferença entre ECONNREFUSED, ENOTFOUND e certificado inválido. Repare que a mensagem do axios é bem mais informativa de graça: ela diz que tentou ::1 e 127.0.0.1, as duas famílias de endereço do localhost.

A segunda é a última linha: tem erro.response? false. Quando não há resposta nenhuma, erro.response é undefined — e o hábito que funcionou tão bem no 404 vira isto:

js
import axios from 'axios';

try {
  await axios.get('http://localhost:4488/pacientes/17');
} catch (erro) {
  // o hábito que funciona no 404 e explode quando a rede cai
  console.log('status da falha:', erro.response.status);
}
file:///private/tmp/pata-amiga/16-erro-response.mjs:7 console.log('status da falha:', erro.response.status); ^

TypeError: Cannot read properties of undefined (reading ‘status’) at file:///private/tmp/pata-amiga/16-erro-response.mjs:7:49 at process.processTicksAndRejections (node:internal/process/task_queues:104:5)

Node.js v24.16.0

É o Cannot read properties of undefined de sempre, nascido dentro do próprio catch. A correção é uma interrogação: erro.response?.status ?? 'sem resposta'. Escreva assim desde o primeiro dia — esse erro só aparece quando a rede cai, ou seja, no pior momento possível.

Peso no bundle e o que isso custa no front

No servidor, o tamanho do axios é irrelevante. No navegador, ele é um custo real que o fetch não tem, porque o fetch já está no aparelho de quem acessa.

Empacotei dois módulos que fazem a mesma busca de paciente — um com axios, outro com fetch — e um terceiro com o cliente completo mostrado acima:

js
// com-axios.js
import axios from 'axios';

const api = axios.create({ baseURL: '/api', timeout: 5000 });

export async function buscarPaciente(id) {
  const { data } = await api.get(`/pacientes/${id}`);
  return data;
}
bash
npx esbuild com-axios.js --bundle --minify --format=esm --platform=browser --outfile=com-axios.min.js
gzip -9 -c com-axios.min.js > com-axios.min.js.gz
wc -c com-axios.min.js com-axios.min.js.gz
módulo minificado gzip
com axios 48.328 B 18.670 B
com fetch puro 198 B 204 B
com o cliente fetch completo 527 B 393 B

18 KB compactados só para falar HTTP. Repare também que os 198 B do fetch puro ficam maiores depois do gzip: o arquivo é tão pequeno que o cabeçalho da compressão pesa mais que o ganho.

E o axios praticamente não encolhe com tree shaking. O bundle acima, que usa exatamente dois recursos (create e get), ficou em 48.328 B — contra 48.485 B do dist/esm/axios.min.js que o próprio pacote já publica pronto. O esbuild conseguiu descartar 157 bytes. O motivo é a arquitetura: o axios é um objeto único com os adaptadores dentro, não um punhado de funções independentes que o empacotador consiga descartar.

E a velocidade? Medi 500 requisições sequenciais contra o Express local, com 200 chamadas de aquecimento descartadas e nove rodadas alternadas de cada biblioteca, num MacBook com Apple M4 Pro, Node 24.16.0:

js
const comFetch = async () => (await fetch(URL)).json();
const comAxios = async () => (await api.get('/pacientes/17')).data;

async function rodada(chamada) {
  const t = performance.now();
  for (let i = 0; i < N; i++) await chamada();
  return performance.now() - t;
}
fetch mediana 40.1 ms / 500 chamadas = 0.080 ms cada axios mediana 55.4 ms / 500 chamadas = 0.111 ms cada diferença por chamada: 0.031 ms

O axios gastou 0,03 ms a mais por chamada — ele monta o envelope de resposta, roda os interceptores e usa o módulo http do Node, enquanto o fetch vai direto pelo undici. Trinta microssegundos. Qualquer requisição que saia da sua máquina custa de 20 a 200 milissegundos, ou seja, mil vezes mais. Escolher cliente HTTP por desempenho é otimizar o item errado.

A tabela de decisão: quando cada um

Depois de rodar tudo isso, a recomendação da casa é simples e assumida:

situação escolha por quê
back-end Node chamando uma ou duas APIs fetch já está lá; zero dependência para auditar e atualizar
back-end com muitas integrações e política de erro comum axios interceptor de resposta centraliza o tratamento
front-end com poucas chamadas fetch 18 KB de gzip a menos no bundle
front-end com login, refresh de token e upload axios interceptor no 401 e onUploadProgress
projeto com React e cache de servidor fetch quem resolve estado é o TanStack Query, não o cliente
script rápido, CLI, cron, função serverless fetch sem node_modules, o cold start agradece
projeto que precisa rodar em Node 16 ou mais antigo axios ali o fetch global ainda não existe

E o critério que resume a tabela: comece com fetch e um wrapper de trinta linhas. Quando o wrapper começar a crescer, quando você precisar de retry com política, refresh de token e barra de progresso ao mesmo tempo, troque por axios sem drama — as chamadas mudam de api('/rota') para api.get('/rota'), e é só isso. O caminho contrário, tirar axios de um projeto grande, é bem mais caro.

O que não é critério: velocidade (0,03 ms), popularidade e “é o que o tutorial usava”.

O que vem depois

Se você chegou aqui pelo lado de quem consome, o passo natural é entender o outro lado do fio: métodos HTTP e status code explica por que o 404 e o 503 deste artigo significam coisas diferentes, e o guia de Node mostra a ordem de estudo até a sua API estar no ar com deploy, healthcheck e variáveis de ambiente.

Se o consumo vai acontecer no navegador, vale revisar a Fetch API no JavaScript do dia a dia e ver como isso vira estado de tela em consumir API no React. Para praticar os mesmos casos sem uma biblioteca por cima, siga para a Fetch API no JavaScript: nem fetch nem axios validam, sozinhos, o formato que a clínica devolve.

Para decidir no seu contexto, faça uma comparação pequena: chame uma rota que responde 404 uma vez com fetch e outra com axios, ambas dentro de try/catch. Confirme que o fetch exige testar response.ok, enquanto axios entra no catch. Depois acrescente timeout às duas chamadas. Se um dos exemplos ficar muito maior que o outro, você encontrou o custo de manutenção que deve pesar mais do que preferência pessoal.

  • node
  • axios
  • fetch
  • http
  • api

Perguntas frequentes

O axios ainda é necessário em 2026?
Necessário, não. O fetch nativo cobre GET, POST, header, cancelamento e timeout sem instalar nada. O axios continua útil quando você quer interceptor de requisição e de resposta, erro de status virando exceção sem esforço e barra de progresso de upload.
Por que o fetch não dá erro quando a API responde 404?
Porque a promessa do fetch só rejeita quando a requisição não completa — DNS que não resolve, conexão recusada, requisição cancelada. Um 404 é uma resposta HTTP bem-sucedida do ponto de vista da rede. Você precisa olhar resposta.ok ou resposta.status na mão.
Preciso instalar node-fetch no Node?
Não. O fetch é global desde o Node 18, saiu de experimental no Node 21 e no Node 24 roda sem nenhum aviso no terminal. O node-fetch só faz sentido em projeto preso a uma versão antiga do Node.
Qual é mais rápido, axios ou fetch?
Medindo 500 requisições sequenciais contra um Express local num MacBook com M4 Pro, o fetch fez cada chamada em 0,080 ms e o axios em 0,111 ms. A diferença de 0,03 ms por chamada desaparece diante de qualquer latência de rede real.
Dá para fazer barra de progresso de upload com fetch?
Não de forma simples. O fetch não expõe evento de progresso de envio, e a alternativa (mandar o corpo como ReadableStream) exige duplex, suporte do servidor e HTTP/2. Com axios é uma opção, onUploadProgress.
axios ou fetch dentro do React?
Os dois funcionam. Na prática, o que resolve estado de carregando, erro e cache no React não é o cliente HTTP, é a camada acima — TanStack Query ou SWR — e as duas aceitam qualquer um dos dois por baixo.

Dúvidas e comentários

Travou em algum passo? Pergunte aqui — a equipe e outros alunos respondem.

Todo o código deste artigo foi executado em Node 24.16.0, e as saídas exibidas são as reais — como produzimos este conteúdo.

Fontes consultadas

  1. MDN — Using the Fetch API — developer.mozilla.org
  2. Node.js — Globals: fetch — nodejs.org
  3. Axios — Interceptors — axios-http.com
  4. MDN — AbortSignal.timeout() — developer.mozilla.org

Continue por aqui