Um campo de busca que dispara uma requisição a cada tecla envia cinco requisições para “redis”, e quatro chegam desatualizadas antes de a resposta voltar. Um handler de scroll que recalcula o layout roda centenas de vezes por segundo e deixa a página travando. O mesmo problema nos dois casos: o evento do DOM dispara muito mais vezes do que o handler precisa rodar. Debounce e throttle resolvem isso de duas formas diferentes.

O que o debounce faz e o que o throttle faz

O debounce espera o evento parar de disparar por um período definido, e só então roda uma vez. Cada nova chamada reinicia o relógio. Se o evento nunca parar, a função nunca roda. O campo de busca é o caso típico: espera o usuário fazer uma pausa ao digitar, e então envia uma requisição com o que ele digitou por último.

O throttle limita a frequência com que a chamada pode rodar. Durante uma rajada, continua rodando, no máximo uma vez por intervalo. Use-o para scroll, onde a página deve continuar se atualizando enquanto o usuário rola, só que não a cada evento individual.

DebounceThrottle
Roda durante a rajadaNão, só depois que ela terminaSim, no máximo uma vez por intervalo
O que você obtémA última chamada da rajadaUma amostra constante durante toda a rajada
Uso típicoCampo de busca, validação de formulário, salvar ao digitarscroll, resize, mousemove, drag
Se a rajada nunca terminaNunca rodaContinua rodando no ritmo definido

Os dois são wrappers. Você passa sua callback e um atraso, e recebe de volta uma função com a mesma assinatura, que anexa ao evento no lugar da original.

Como escrever uma função de debounce

1
2
3
4
5
6
7
function debounce(fn, delay) {
  let timer;
  return function (...args) {
    clearTimeout(timer);
    timer = setTimeout(() => fn.apply(this, args), delay);
  };
}

Cada chamada cancela o setTimeout pendente e agenda um novo, então só a última chamada da rajada vive tempo suficiente para disparar. O timing segue as regras do setTimeout explicadas em como o event loop do JavaScript agenda callbacks: a callback nunca roda antes do delay, e pode rodar mais tarde se a call stack estiver ocupada quando o timer expirar.

Ligado a um campo de busca:

1
2
3
4
5
6
7
const input = document.querySelector("#search");
const search = debounce((value) => {
  console.log("searching for:", value);
  fetch(`/api/search?q=${encodeURIComponent(value)}`);
}, 300);

input.addEventListener("input", (e) => search(e.target.value));

Digite “redis” num ritmo normal e o console mostra uma única linha, searching for: redis, cerca de 300ms depois da última tecla. Não cinco linhas para cinco letras.

Como escrever uma função de throttle

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
function throttle(fn, interval) {
  let last = 0;
  return function (...args) {
    const now = Date.now();
    if (now - last >= interval) {
      last = now;
      fn.apply(this, args);
    }
  };
}

Essa é a versão leading-edge. A primeira chamada de uma janela roda imediatamente; tudo que chega depois é descartado até passarem interval milissegundos.

1
2
3
4
5
const onScroll = throttle(() => {
  console.log("scrollY:", window.scrollY);
}, 200);

window.addEventListener("scroll", onScroll);

Role continuamente por dois segundos e você obtém cerca de dez linhas de log espaçadas uniformemente, em vez das centenas de eventos scroll que o navegador dispara de fato.

Opções de debounce e throttle do lodash: leading, trailing, maxWait

Cada uma das funções escritas à mão acima fixa uma única escolha. O debounce só dispara na trailing edge, depois do período de silêncio. O throttle só dispara na leading edge, no instante em que uma janela abre, então a última chamada de uma rajada se perde se cair no meio da janela. Isso importa para scroll: a última posição em que o usuário parou pode nunca ser tratada.

O _.debounce(fn, wait, options) e o _.throttle(fn, wait, options) do lodash tornam as duas bordas configuráveis pelos booleanos leading e trailing, e os padrões são diferentes. O debounce usa por padrão leading: false, trailing: true: espera, e só então dispara. O throttle usa por padrão os dois true: dispara imediatamente, e mais uma vez no fim de uma rajada que durou mais que um intervalo. Internamente, o throttle do lodash é o próprio debounce com maxWait igual ao intervalo, então os dois estão mais próximos do que os nomes sugerem.

maxWait é uma opção do debounce que limita por quanto tempo a primeira chamada pode ser adiada. Sem ela, alguém digitando de forma constante com pausas menores que o atraso nunca dispara o handler, mesmo depois de dez segundos de atividade. Com maxWait: 2000, ele dispara pelo menos a cada dois segundos.

O debounce só-leading cobre o caso do envio duplicado. _.debounce(sendMail, 300, { leading: true, trailing: false }) roda no primeiro clique e ignora os cliques seguintes até passarem 300ms sem nenhum clique. O primeiro clique conta; os demais são descartados, não atrasados.

Quais eventos precisam de debounce e quais de throttle

EventoEscolhaPor quê
Busca enquanto digitaDebounceSó a consulta final importa; as teclas anteriores geram requisições que você descartaria
resize da janelaDebounce ou throttleDebounce se só importa o tamanho final (recalcular o layout uma vez); throttle se a interface deve acompanhar visivelmente o redimensionamento
scrollThrottleA página deve continuar respondendo durante toda a rolagem, não só depois que o usuário parar
mousemove (drag, tooltip que segue)Throttle, via requestAnimationFrameAtualizações mais rápidas que a taxa de atualização da tela são trabalho perdido
Clique em botão (evitar envio duplicado)Debounce só-leading ou desabilitar ao clicarO primeiro clique deve contar; os demais devem ser ignorados, não atrasados
Salvamento automático a cada teclaDebounce com maxWaitSalva depois de uma pausa, mas garante um salvamento durante digitação contínua longa

Quando o handler controla algo visual (um drag, um tooltip que segue o cursor, uma animação ligada ao scroll), troque Date.now por requestAnimationFrame:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
function rafThrottle(fn) {
  let scheduled = false;
  return function (...args) {
    if (scheduled) return;
    scheduled = true;
    requestAnimationFrame(() => {
      fn.apply(this, args);
      scheduled = false;
    });
  };
}

A frequência das chamadas agora segue a taxa de pintura do navegador em vez de um número de milissegundos escolhido à parte. A atualização roda uma vez por frame, pouco antes de o frame ser desenhado, então não pode rodar mais vezes do que alguém conseguiria ver e não se desalinha da renderização quando a thread principal está sob carga.

Erros comuns com debounce e throttle

Criar a função debounced dentro do handler. No React, onChange={(e) => debounce(handleSearch, 300)(e.target.value)} chama debounce() a cada tecla, e cada chamada retorna um wrapper novo com seu próprio timer vazio. Nunca existe um timer anterior para cancelar, então cada tecla ainda dispara uma requisição, só que 300ms mais tarde. Crie a função debounced uma única vez (useMemo, useRef, ou escopo de módulo fora de um componente) e chame sempre essa mesma instância.

Não cancelar ao desmontar. Um salvamento automático debounced que dispara depois que o usuário já navegou para outra página escreve num componente que não existe mais, ou salva a partir de uma closure obsoleta. As funções debounced e throttled do lodash já têm um método .cancel(); para o debounce feito à mão, adicione um que chame clearTimeout(timer). Chame-o quando o componente desmontar ou a página for descarregada.

Testar com timers reais. Esperar um debounce com await new Promise(r => setTimeout(r, 300)) funciona, mas adicione isso a cada teste de timing e a suíte fica lenta o bastante para que ninguém mais a rode localmente. Use timers falsos: vi.useFakeTimers() no Vitest ou jest.useFakeTimers() no Jest, e avance o relógio com vi.advanceTimersByTime(300). Um timer falso é um test double para o relógio, não para o seu código. É a mesma distinção traçada em teste unitário vs teste de integração vs end-to-end.

Aplicar debounce a algo que precisa parecer ao vivo. Uma barra de progresso ou um manípulo de drag que só se move quando o usuário para parece quebrado. Se o requisito é “atualizar com fluidez enquanto isso continua acontecendo”, o que você quer é throttle.

Usar debounce ou throttle por padrão?

Use debounce quando só o estado final importa: campos de busca, salvamento automático, validar um campo depois que o usuário parar de digitar. Use throttle quando o usuário espera ver progresso enquanto algo continua, como scroll, resize ou drag, e troque para a versão com requestAnimationFrame quando o resultado for uma atualização visual.

Em produção, importe debounce e throttle de uma biblioteca mantida (lodash, ou um hook como use-debounce no React) em vez de usar as versões acima. Elas existem para mostrar o mecanismo. As versões da biblioteca já lidam com cancelamento, maxWait, chamadas trailing e o binding de this, exatamente onde uma cópia feita à mão costuma quebrar.

Artigos relacionados