Debounce e Throttle em JavaScript: cancelar fetchs com AbortController
Por John Pires · 08 de março de 2026

Introducao ao problema
Inputs que disparam requisições em sequência são comuns em aplicações web: pesquisas ao digitar, autocompletes, filtros que atualizam resultados a cada alteração. Sem controle, cada tecla pode gerar uma request. Isso causa tráfego desnecessário, maior latência percebida e problemas de race conditions (respostas chegam fora de ordem).
Debounce e throttle são padrões para reduzir a frequência de chamadas. Debounce aguarda o fim de uma sequência de eventos antes de executar a ação; throttle garante execução no máximo uma vez por intervalo. Em ambos os casos, cancelar requisições pendentes evita trabalho desnecessário no servidor e evita estados inconsistentes no cliente.
AbortController é a API moderna para cancelar fetchs. Usá-lo combinado com debounce/throttle é a forma correta de evitar respostas antigas sobrescrevendo a UI e de liberar recursos de rede.
O problema em termos simples
Imagine um usuário digitando "javascript" em um campo de pesquisa. A cada tecla é disparado um fetch para sugerir resultados. Se as respostas chegarem fora de ordem, a UI pode mostrar uma sugestão para "java" depois que o usuário já escreveu "javascript". Cancelar requisições antigas e evitar spam de chamadas resolve isso.
Debounce sozinho ajuda, mas não cancela fetchs iniciados antes do intervalo terminar; se uma requisição lenta iniciar e depois o usuário disparar outra, precisamos abortar a requisição lenta.
Como este artigo está organizado
Vou mostrar primeiro o jeito errado (code review: o que quebra), depois a forma correta usando AbortController. Haverá exemplos passo a passo, armadilhas comuns e um desafio ao final para praticar.
Dica: acompanhar o fluxo de eventos com console.log ajuda a entender ordem e quando abortar. Mas logs não resolvem a race condition — o cancelamento sim.
Pre-requisitos e setup
Conhecimentos mínimos
É preciso saber o básico de JavaScript: funções, promessas (Promise), fetch, closures e eventos de DOM. Também ajuda entender o event loop, porque debounce e throttle dependem de timers.
Explicação rápida: uma closure permite que uma função interna acesse variáveis do escopo externo mesmo após o escopo externo ter sido executado. Debounce utiliza essa propriedade para manter um timer e, opcionalmente, uma referência ao AbortController.
Ambiente e arquivos
Você não precisa de bundler. Crie um arquivo index.html simples com um input e um script. Exemplo mínimo de HTML para testar:
<!-- index.html: input para digitar e testar debounce/throttle -->
<!doctype html>
<html lang="pt-BR">
<head>
<meta charset="utf-8">
<title>Demo Debounce/Throttle</title>
</head>
<body>
<input id="search" placeholder="Digite para buscar..." />
<div id="results"></div>
<script src="app.js"></script>
</body>
</html>
O arquivo app.js conterá os exemplos: primeiro o exemplo vazio (errado), depois as correções com AbortController.
Tutorial guiado passo a passo
1) Jeito errado: debounce sem cancelar fetch
Mostro um debounce simples que atrasa execuções, mas ignora cancelamento das requisições iniciadas. Isso leva a race conditions quando respostas chegam fora de ordem.
Contexto: este código cria uma função debounce que espera 300ms sem eventos para disparar uma busca via fetch. Problema: se um fetch lento iniciar e o usuário disparar outro, o fetch lento seguirá até completar.
// app.js - exemplo errado
function debounce(fn, wait) {
let timeout;
return function(...args) {
clearTimeout(timeout);
timeout = setTimeout(() => fn.apply(this, args), wait);
};
}
const input = document.getElementById('search');
const results = document.getElementById('results');
async function doSearch(query) {
// fetch sem cancelamento
const res = await fetch('/search?q=' + encodeURIComponent(query));
const data = await res.json();
results.textContent = JSON.stringify(data);
}
const debounced = debounce((e) => doSearch(e.target.value), 300);
input.addEventListener('input', debounced);
O que acontece: o debounce reduz número de chamadas, mas cada chamada de fetch é ininterrupta. Se uma resposta antiga chega por último, ela sobrescreve dados recentes. Há desperdício de banda e processamento.
Por que isso é ruim (erros comuns)
Erros que aparecem nesse padrão:
- Race condition: resultados incorretos mostrados.
- Recursos de rede desperdiçados se o usuário digita rápido.
- Dificuldade em limpar pending requests ao desmontar componentes.
É comum começar assim por simplicidade, mas é frágil em produção.
2) Forma correta: debounce com AbortController
Agora vou integrar AbortController ao debounce. A ideia: quando um novo fetch vai iniciar, abortamos o fetch anterior (se existir). Mantemos a referência ao controller dentro da closure do debounce.
Contexto antes do código: o debounce precisa armazenar tanto timeout quanto um AbortController ativo. Quando o timer dispara e chamamos a função que faz o fetch, criamos um novo AbortController e passamos seu signal para fetch. Se um novo evento chegar antes da resposta, chamamos controller.abort().
// app.js - debounce com AbortController
function debounceWithAbort(fn, wait) {
let timeout;
let controller; // controller da requisição atual
return function(...args) {
clearTimeout(timeout);
// se já existe uma requisição pendente, aborta agora
if (controller) {
controller.abort();
controller = undefined;
}
timeout = setTimeout(() => {
controller = new AbortController();
fn.apply(this, [...args, controller.signal]);
}, wait);
};
}
async function searchFetch(query, signal) {
try {
const res = await fetch('/search?q=' + encodeURIComponent(query), { signal });
if (!res.ok) throw new Error('HTTP ' + res.status);
const data = await res.json();
results.textContent = JSON.stringify(data);
} catch (err) {
if (err.name === 'AbortError') {
console.log('fetch abortado para', query);
} else {
console.error('erro na busca', err);
}
}
}
const debouncedAbort = debounceWithAbort(searchFetch, 300);
input.addEventListener('input', (e) => debouncedAbort(e.target.value));
O que acontece: quando o usuário digita rápido, requisições anteriores são abortadas. Apenas a última, ou a que realmente for disparada, chega até completar. Isso evita race conditions e reduz tráfego.
Dicas práticas sobre AbortController
AbortController só cancela fetch (e APIs que escutam signal). Se sua função envolver outras operações (timers, workers), faça limpeza manual. Lembre-se que abortar levanta um erro com name === 'AbortError' em fetch.
Não reutilize o mesmo controller para várias requisições simultâneas — crie um novo para cada fetch que você pretende poder abortar individualmente.
3) Throttle com cancelamento
Throttle permite executar uma ação no máximo uma vez a cada intervalo. Combine com AbortController para garantir que uma requisição em andamento seja abortada antes de iniciar a próxima se desejar esse comportamento.
Contexto: é útil quando você quer limitar a frequência das requisições, mas também quer garantir que uma requisição anterior não interfira nos resultados se ela for mais lenta.
// app.js - throttle com AbortController
function throttleWithAbort(fn, limit) {
let inThrottle = false;
let controller;
return function(...args) {
if (!inThrottle) {
// antes de iniciar, aborta anterior (se houver)
if (controller) {
controller.abort();
}
controller = new AbortController();
fn.apply(this, [...args, controller.signal]);
inThrottle = true;
setTimeout(() => inThrottle = false, limit);
} else {
// opcional: aborta qualquer anterior e re-agenda? depende do caso
console.log('em throttle, ignorando evento');
}
};
}
const throttled = throttleWithAbort(searchFetch, 1000);
input.addEventListener('input', (e) => throttled(e.target.value));
Neste padrão, se o usuário inserir valores rapidamente, apenas a primeira requisição em cada intervalo será enviada. Se quiser garantir que a última seja enviada após o throttle, combine throttle com lógica de fila ou um último debounce.
Resultado final funcionando
Testando localmente
Para testar em desenvolvimento sem um backend real, crie um endpoint fake usando express ou um stub fetch. Exemplo de servidor simples em Node.js para simular latências:
// server.js - exemplo rápido com express para simular atraso
const express = require('express');
const app = express();
app.get('/search', (req, res) => {
const q = req.query.q || '';
// simula latência variável
const delay = Math.random() * 800 + 200; // 200ms a 1000ms
setTimeout(() => {
res.json({ q, results: [q + '-1', q + '-2', q + '-3'], delay });
}, delay);
});
app.listen(3000, () => console.log('mock server on http://localhost:3000'));
Inicie o servidor e ajuste fetch('/search?...') para apontar para 'http://localhost:3000/search?q=' no app.js. Teste digitando rapidamente e observe no console os aborts e a UI permanecendo consistente.
Comportamento esperado
Com debounce+AbortController: poucas requisições reais ao servidor, requisições anteriores abortadas, UI com dados corretos para o termo mais recente.
Com throttle+AbortController: requisições limitadas por intervalo; se você optar por abortar quando iniciar nova, não verá respostas antigas sobrescreverem a UI.
Em ambos os casos você reduz carga no backend e entrega uma experiência mais responsiva.
Erros comuns, armadilhas e dicas
Armadilhas ao usar AbortController
AbortController não remove efeitos colaterais que seu código tenha iniciado além de fetch. Se sua lógica cria timers, subscriptions em WebSockets ou altera estado, você precisa limpar manualmente.
Outro erro: não tratar o erro AbortError. Se você usar async/await, encapsule em try/catch e verifique err.name === 'AbortError' para ignorar logs de erro ruidosos.
Debounce/throttle mal combinados
Debounce e throttle têm propósitos diferentes. Misturá-los sem critério cria comportamento inesperado. Por exemplo, debouncing + throttling pode atrasar demais a ação ou fazer com que a última mudança seja ignorada. Pense qual UX você deseja: executar apenas no fim (debounce) ou garantir atualizações periódicas (throttle).
Use padrões simples primeiro e only add complexity quando necessário.
Desafio para o leitor praticar
Desafio 1: busca com cancelamento e cache simples
Implemente um cache em memória (Map) que armazene resultados por query. Antes de fazer fetch, cheque o cache. Se não estiver no cache, faça fetch com AbortController. Combine com debounce para evitar chamadas enquanto digita. Objetivo: reduzir fetches ainda mais e garantir resposta rápida para termos repetidos.
Pistas:
- Use Map para chavear pelo termo pesquisado.
- Ao receber resposta bem sucedida, armazene no cache.
- Se uma requisição for abortada, não armazenar nada.
Desafio 2: componente que cancela ao desmontar
Se você estiver usando frameworks como React, garanta que o AbortController seja abortado no unmount (useEffect cleanup). Em vanilla JS, você pode abortar ao remover o input ou ao trocar de rota. Implemente um cleanup que aborte requisições ativas e remova event listeners para evitar memory leaks.
Conclusao pratica
Debounce e throttle são ferramentas para controlar frequência de execução. Sozinhos reduzem chamadas, mas não evitam que fetchs antigos resolvam e poluam a UI. AbortController é essencial para cancelar requisições pendentes de forma limpa.
Regra prática: quando você dispara uma nova requisição cujo resultado tornará irrelevante o resultado anterior, crie e armazene um AbortController e aborte o anterior.
Resumo de passos práticos:
- Escolha debounce ou throttle conforme o comportamento desejado.
- Em cada execução assíncrona, crie um AbortController e passe signal para fetch.
- Aborte controllers anteriores antes de iniciar uma nova requisição relevante.
- Trate AbortError no catch para evitar logs desnecessários.
Implementar essas práticas diminui bugs visuais, economiza banda e deixa a aplicação mais previsível. Experimente os desafios e combine cache + abort + debounce para melhorias perceptíveis.
Boa prática final: nomes claros para controllers e timers dentro de closures facilitam o entendimento e manutenção. Pequenas funções puras e tratamento explícito de erros tornam o comportamento previsível.
Assuntos: JavaScript, abortcontroller, debounce, throttle
Leia também
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…
Form-Associated Custom Elements para formulários semânticos e acessíveis
Guia técnico para construir elementos customizados form-associated que integram corretamente com FormData, validação nativa, labels e acessibilidade. Mostra o…
Formulários condicionais semânticos: padrão acessível com fallback sem JS
Padrão semântico para formulários condicionais que funciona sem JavaScript: radios/:checked, <details> e <noscript> para fallback. Guia prático, código HTML…
Retry resiliente com fetch: AbortController, backoff exponencial e streaming
Padrões práticos para retries resilientes com fetch: combine AbortController para cancelamento, backoff exponencial com jitter para evitar picos, e streaming…
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…
WebAssembly com Rust para o navegador: compilar e usar com JS (iniciantes)
Guia prático para iniciantes: compile Rust para WebAssembly e consuma no navegador via JS. Passo a passo para tokenizacao, cálculo de scores e integração com…