Retry resiliente com fetch: AbortController, backoff exponencial e streaming
Por John Pires · 20 de março de 2026

Problema real que resolve
Requests de rede falham. Nem sempre por um erro definitivo do servidor: conexoes móveis oscilam, servidores temporariamente limitam taxa, gateways terminam requests lentos e o cliente precisa reagir. Sistemas web modernos precisam de strategies para reintentar chamadas, abortar operações longas e processar respostas em streaming sem desperdiçar memória.
Este artigo aborda o problema prático de compor retries robustos usando a API fetch, combinando AbortController para cancelamento determinístico, backoff exponencial com jitter para evitar thundering herds, e leitura de streaming para processar dados parcialmente recebidos.
Você vai aprender a construir uma função utilitária reutilizável que trata tentativas, timeouts por tentativa, timeout total, cancelamento pelo usuário e leitura incremental da resposta, com exemplos práticos testáveis.
O foco é em padrões e armadilhas operacionais: quando reintentar, quando abortar, como medir e como integrar com UI e logs. Não é um tutorial de fetch básico: assume que conhece promises, async/await, e o básico de AbortController.
Cenários comuns
Interações típicas que nos motivam:
- Upload ou download que pode travar pela perda intermitente de pacote.
- Requests para microserviços que respondem com 5xx esporádicos.
- Operações iniciadas pelo usuário que devem ser abortadas se o usuário navegar para outra página.
Impactos no produto
Sem retries controlados, a UX fica frágil: operações que poderiam ser concluídas com uma segunda tentativa falham para o usuário. Sem abortos, threads/handlers ficam pendurados, consumindo recursos. Sem streaming, a aplicação pode gastar memória desnecessária ao fazer await do body inteiro quando só precisa processar partes.
Abordagem ingenua vs abordagem otimizada
Abordagem ingenua
A solução comum é simples: reexecutar fetch em caso de erro e esperar um tempo fixo entre tentativas. Exemplo padrão: tentar 3 vezes com setTimeout de 1000ms. Isso funciona em pouco caso, mas falha em produção por dois motivos principais.
Primeiro, retries sem backoff causam picos de carga nos serviços: em um incidente, centenas de clientes fazem novas tentativas simultâneas, gerando ainda mais erros. Segundo, sem abortos por tentativa você acumula timers ou fetch pendentes que podem se sobrepor e provocar estados inconsistentes no cliente.
Abordagem otimizada
Melhor técnica combina:
- Backoff exponencial com jitter para espaçar tentativas de forma estocástica.
- AbortController por tentativa e um AbortController superior para timeout global ou cancelamento pelo usuário.
- Streaming do response.body para começar a processar dados já enquanto chegam, e abortar leitura se a operação for cancelada.
Esses três elementos juntos entregam uma solução resiliente, controlável e observável.
Implementacao com codigo real
Aqui vem a implementação central: uma função fetchWithRetry que:
- recebe url e options compatíveis com fetch
- suporta maxAttempts, initialDelay, factor e jitter
- aplica timeout por tentativa e timeout total
- retorna a Response ou lança em caso de falha definitiva
Utilitários: sleep, randomJitter e timeoutSignal
Contexto: funções pequenas que serão usadas internamente. O bloco abaixo implementa sleep e um criador de AbortSignal que expira após um timeout.
const sleep = ms => new Promise(res => setTimeout(res, ms))
function timeoutSignal(ms) {
const controller = new AbortController()
const id = setTimeout(() => controller.abort(), ms)
// permite limpar manualmente
return { signal: controller.signal, clear: () => clearTimeout(id) }
}
function randomJitter(base) {
// jitter entre 0.5x e 1.5x do valor
return base * (0.5 + Math.random())
}
Depois desse código, temos helpers reutilizáveis: sleep, timeoutSignal com clear e jitter. Eles são simples, mas essenciais para construir backoff seguro.
API principal: fetchWithRetry
Contexto: a função abaixo combina per-attempt AbortController com um AbortController global opcional. Ela também aplica backoff exponencial com jitter e trata respostas com status que merecem retry (por exemplo 502, 503, 504).
async function fetchWithRetry(url, opts = {}) {
const {
maxAttempts = 4,
initialDelay = 300,
factor = 2,
attemptTimeout = 10000,
totalTimeout = 30000,
shouldRetry = resp => resp && [502, 503, 504].includes(resp.status),
signal: userSignal
} = opts
const total = timeoutSignal(totalTimeout)
if (userSignal) {
// se o usuario passar um signal, abortamos todos em cascata quando ele abortar
if (userSignal.aborted) {
total.clear()
throw new DOMException('Aborted by user', 'AbortError')
}
userSignal.addEventListener('abort', () => {
// convocamos abort global
try { ;(total.signal as any).throwIfAborted?.() } catch (e) {}
})
}
let attempt = 0
let lastError = null
while (attempt < maxAttempts) {
attempt++
const attemptController = new AbortController()
const attemptTimeoutRef = timeoutSignal(attemptTimeout)
// compor signals: se qualquer um abortar, a fetch deve abortar
const combinedController = new AbortController()
const onAbort = () => combinedController.abort()
total.signal.addEventListener('abort', onAbort)
attemptController.signal.addEventListener('abort', onAbort)
if (userSignal) userSignal.addEventListener('abort', onAbort)
try {
const response = await fetch(url, {
...opts,
signal: combinedController.signal
})
if (!shouldRetry(response)) {
attemptTimeoutRef.clear()
total.clear()
total.signal.removeEventListener('abort', onAbort)
return response
}
// se precisa retry, consome o corpo curto e prepara para nova tentativa
lastError = new Error('Retryable response status: ' + response.status)
} catch (err) {
// fetch lançou (network error ou abort)
lastError = err
if (err && err.name === 'AbortError') {
// se foi abort por timeout total, propaga imediatamente
if (total.signal.aborted) {
total.clear()
throw new DOMException('Total timeout', 'AbortError')
}
// caso contrário, continuamos para tentar novamente se houver tentativas
}
} finally {
attemptTimeoutRef.clear()
total.signal.removeEventListener('abort', onAbort)
}
if (attempt >= maxAttempts) break
const delay = Math.round(randomJitter(initialDelay * Math.pow(factor, attempt - 1)))
// espera antes da proxima tentativa
await sleep(delay)
}
total.clear()
throw lastError || new Error('fetchWithRetry failed')
}
Explicação: por tentativa criamos controllers isolados e um controller global para o timeout total. A função shouldRetry permite customizar quando reintentar (por padrão 502/503/504). O jitter evita sincronização entre clientes.
Streaming e cancelamento
Contexto: ler o response.body em streaming permite processar dados antes de terminar o download. Abaixo, um leitor que recebe uma Response e consome o body com getReader(), propagando abort se o signal for abortado.
async function streamToCallback(response, { onChunk, onComplete, signal }) {
if (!response.body) throw new Error('Response body not supported')
const reader = response.body.getReader()
const abortPromise = new Promise((_, reject) => {
if (signal) signal.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError')))
})
try {
while (true) {
const { done, value } = await Promise.race([reader.read(), abortPromise])
if (done) break
onChunk(value)
}
onComplete()
} finally {
try { reader.releaseLock() } catch (e) {}
}
}
Uso prático: combine fetchWithRetry para obter a Response e passe o response para streamToCallback. Se o usuário abortar, passe o mesmo signal para que a leitura seja interrompida e recursos liberados.
Exemplo de uso integrando tudo:
async function downloadAndProcess(url, opts = {}) {
const userController = new AbortController()
const response = await fetchWithRetry(url, { ...opts, signal: userController.signal })
await streamToCallback(response, {
signal: userController.signal,
onChunk: chunk => {
// chunk e um Uint8Array
console.log('received bytes', chunk.length)
// processe incrementalmente
},
onComplete: () => console.log('done')
})
// caller pode chamar userController.abort() para cancelar tudo
}
Após esse código, temos uma cadeia: usuário pode abortar via userController, fetchWithRetry respeita o signal e tentativas são feitas com backoff. A leitura em streaming processa dados aos poucos.
Testes e validacao
Testar redes é difícil sem mocks. Aqui existem duas abordagens: usar um mock fetch em unit tests ou iniciar um servidor local que simula latência e falhas.
Testes locais com mock fetch
Contexto: abaixo um mock simples que simula falhas nas primeiras duas tentativas e depois retorna um stream com três chunks. Isso permite validar retries e streaming sem rede.
// mockFetch simula: 2 falhas de rede, depois resposta com stream
function makeMockFetch() {
let calls = 0
return async function mockFetch(url, opts) {
calls++
if (calls <= 2) {
// simula falha network
return Promise.reject(new TypeError('NetworkError'))
}
// cria um ReadableStream que envia 3 chunks
const encoder = new TextEncoder()
const stream = new ReadableStream({
start(controller) {
controller.enqueue(encoder.encode('chunk1\n'))
setTimeout(() => controller.enqueue(encoder.encode('chunk2\n')), 50)
setTimeout(() => {
controller.enqueue(encoder.encode('chunk3\n'))
controller.close()
}, 100)
}
})
return new Response(stream, { status: 200 })
}
}
// substituir global.fetch para testar
const originalFetch = globalThis.fetch
globalThis.fetch = makeMockFetch()
// executar a função de download e ver logs
await downloadAndProcess('https://example/stream')
// restaurar
globalThis.fetch = originalFetch
Esse teste verifica que as duas primeiras tentativas falham, depois a terceira retorna dados em stream e nosso streamToCallback processa cada chunk. Use assertions em frameworks como vitest/jest para validar chamadas e tempos.
Medição de latência e comportamento
Contexto: além de funcionalidade, meça tempo entre tentativas e número de tentativas para entender impacto do backoff. Instrumente a função fetchWithRetry com eventos de log e métricas.
Exemplo de métrica simples: incrementar contador de attempts, registrar latência por tentativa e expor um histograma de tempo total. Em produção, envie para um APM ou sistema de métricas.
Tip: logue 'attempt number' e 'delay used' em debug level. Esses dados ajudam a ajustar initialDelay e factor sem trocar código no escuro.
Consideracoes de producao
Politicas e limites
Defina limites claros: maxAttempts, totalTimeout e attemptTimeout devem ser parametrizaveis por endpoint. Operações idempotentes podem ser reexecutadas mais livremente; operações que criam recursos podem precisar de idempotency keys ou serem proibidas de retry automático.
Evite retries automáticos para requests com efeitos colaterais quando não houver controle de idempotência. Para POSTs críticos, prefira mecanismos de fila ou confirmações atômicas no servidor.
Observabilidade e tracing
Inclua cabeçalhos de tracing e um identificador por tentativa no log. Informações importantes para correlacionar retries em logs:
- request id
- attempt number
- delay usado
- status code ou erro
- total time to success or failure
Integre com sistemas de monitoramento para identificar endpoints que escalam mal com retries e ajustar backoff dinâmico quando necessário.
Erros comuns e armadilhas:
- Não limpar timers de timeout leva a memory leaks. Sempre chame clear em timeoutSignal quando apropriado.
- Reexecutar fetch sem construir novo AbortController por tentativa significa que aborts podem afetar todas as tentativas de forma inesperada.
- Fazer retry de requests não idempotentes sem coordenação pode duplicar efeitos no servidor.
Conclusao pratica
Implementar retries resilientes com fetch exige três peças coordenadas: backoff exponencial com jitter, AbortController para cancelamento explícito e leitura em streaming para processar dados incrementais. Juntas, essas práticas reduzem erros temporários, protegem recursos do cliente e melhoram a experiência do usuário.
Resumo da receita prática:
- Use per-attempt e global AbortController para controle fino e timeout total.
- Adote backoff exponencial com jitter e parâmetros ajustáveis por endpoint.
- Prefira streaming para dados grandes ou para começar a processar antes da conclusão.
- Instrumente e monitore attempts, latência e taxas de sucesso para ajustar parâmetros em produção.
Implementações como a apresentada aqui são compostas, testáveis e prontas para integração com UI e observability. Adapte shouldRetry e limites ao seu domínio e trate cuidadosamente requests com efeitos colaterais.
Por fim, um lembrete técnico direto: retries são poderosos, mas perigosos quando aplicados sem conhecimento do efeito da operação. Use idempotência, traceamento e métricas para operar em produção com segurança.
Assuntos: JavaScript, fetch, retry, abortcontroller, backoff, streaming
Leia também
Scheduler cooperativo no browser com MessageChannel e requestIdleCallback
Scheduler cooperativo que combina MessageChannel e requestIdleCallback para dividir trabalho pesado em fatias, manter a UI responsiva, permitir cancelamento e…
Criando um simulador interativo do Event Loop: Call Stack, Tasks e Microtasks
Aprenda, do zero, como call stack, microtasks e tasks interagem no event loop. Construa um simulador interativo que demonstra a ordem de execução, evite…
Implementando Token Bucket no Service Worker para controlar fetchs entre abas
Token Bucket no Service Worker: controle de fetchs entre abas com estado centralizado, refill baseado em timestamps, enfileiramento seguro e persistência em…
Pool de concorrência com Async Iterators e AbortSignal
Implementar um pool de concorrência com Async Iterators e AbortSignal para controlar tarefas assíncronas, permitir cancelamento cooperativo e aplicar…
Download resumível no browser com Fetch Streams, Range, IndexedDB e AbortSignal
Como implementar downloads resumíveis no browser: use Fetch Streams para ler incrementos, Range para pedir partes, IndexedDB para armazenar chunks e…
Debounce e Throttle em JavaScript: cancelar fetchs com AbortController
Aprenda, passo a passo, a usar debounce e throttle com AbortController para cancelar fetchs em JavaScript. Evite race conditions, reduza tráfego e mantenha…