Destino Dev

Blog

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:

  1. Use Map para chavear pelo termo pesquisado.
  2. Ao receber resposta bem sucedida, armazene no cache.
  3. 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