Programação Por Equipe DevBras

Capa do artigo: Busca enquanto digita: debounce, AbortController e a resposta fora de ordem

Busca enquanto digita: debounce, AbortController e a resposta fora de ordem

Espere a pausa na digitação, cancele o pedido anterior com AbortController e descarte resposta velha, para a lista sempre bater com o campo sem afogar o servidor.

A pessoa digita "cafeteira" no catálogo e a lista mostra resultados para "caf". O campo diz uma coisa, a lista diz outra. Não é bug de busca: foram nove requisições, uma por tecla, e a resposta de "caf" demorou mais que as outras e chegou por último, por cima da resposta certa.

A resposta curta: espere uma pausa curta na digitação antes de buscar (debounce), cancele a requisição anterior com AbortController e ignore qualquer resposta que não corresponda ao texto atual do campo. As três coisas juntas. Só o debounce diminui o problema, mas não o elimina.

Por que a lista mostra o resultado errado

Requisições HTTP não chegam na ordem em que saíram. Cada uma passa por rede, fila do servidor, consulta no banco e volta. Buscar "caf" pode devolver 2.000 produtos e levar 800 ms; buscar "cafeteira" pode devolver 12 e levar 90 ms. Se o código simplesmente desenha a lista a cada resposta, a última a chegar vence, e ela pode ser a mais antiga.

O custo também é real. Uma pessoa digitando a uns 150 ms por tecla gera 9 requisições para "cafeteira". Com 300 pessoas buscando ao mesmo tempo na campanha de fim de ano, são 2.700 consultas onde 300 bastariam. Se a consulta usa LIKE '%termo%', que não aproveita índice comum, o banco sente na hora.

E tem o lado humano. Lista que pisca a cada tecla distrai, e se o código mover o foco para os resultados, quem está digitando perde o cursor no meio da palavra.

As três defesas e o que cada uma resolve

Debounce: esperar a pausa

Cada tecla reinicia um temporizador. A busca só sai quando a pessoa para de digitar por um intervalo, algo entre 250 e 400 ms costuma funcionar bem. Isso reduz drasticamente o número de requisições, mas não resolve a ordem: se a pessoa pausa em "caf", digita o resto e pausa de novo, ainda são duas requisições em voo.

AbortController: cancelar o que ficou velho

Antes de disparar uma busca nova, você aborta a anterior. O fetch abortado rejeita com um erro de nome AbortError, e a resposta velha nunca chega ao seu código. Abortar no navegador não garante que o servidor parou de processar, mas economiza banda e elimina a corrida no cliente.

Conferir o termo: a última trava

Mesmo com cancelamento, vale comparar o termo que gerou a resposta com o que está no campo antes de desenhar. É barato e cobre casos como cache, service worker ou bibliotecas que não repassam o sinal de cancelamento.

Como implementar, passo a passo

  1. Escute o evento input do campo, não keyup, para pegar colar, apagar e preenchimento automático.
  2. Ignore termos curtos demais (menos de 2 caracteres, por exemplo) e limpe a lista quando o campo ficar vazio.
  3. Reinicie um temporizador a cada evento e só busque quando ele disparar.
  4. Aborte a requisição anterior e crie um AbortController novo para a atual.
  5. Antes de desenhar, confira se o termo ainda é o do campo.
  6. Atualize uma região de status com a quantidade de resultados, sem mexer no foco.
  7. Permita que Enter busque imediatamente, sem esperar o temporizador.
const campo = document.getElementById('busca');
const status = document.getElementById('busca-status'); // <p id="busca-status" role="status">
const ESPERA_MS = 300;
let temporizador;
let controlador;

campo.addEventListener('input', () => {
  clearTimeout(temporizador);
  const termo = campo.value.trim();
  if (termo.length < 2) {
    if (controlador) controlador.abort();
    limparLista();
    status.textContent = '';
    return;
  }
  temporizador = setTimeout(() => buscar(termo), ESPERA_MS);
});

campo.addEventListener('keydown', (e) => {
  const termo = campo.value.trim();
  if (e.key === 'Enter' && termo.length >= 2) {
    e.preventDefault();
    clearTimeout(temporizador);
    buscar(termo);
  }
});

async function buscar(termo) {
  if (controlador) controlador.abort();
  controlador = new AbortController();
  status.textContent = 'Buscando...';
  try {
    const url = '/api/produtos?q=' + encodeURIComponent(termo);
    const r = await fetch(url, { signal: controlador.signal });
    if (!r.ok) throw new Error('HTTP ' + r.status);
    const dados = await r.json();
    if (termo !== campo.value.trim()) return;
    desenharLista(dados.itens);
    status.textContent = dados.itens.length === 0
      ? 'Nenhum produto para "' + termo + '".'
      : dados.itens.length + ' produtos para "' + termo + '".';
  } catch (e) {
    if (e.name === 'AbortError') return;
    status.textContent = 'Não foi possível buscar agora. Tente de novo.';
  }
}

Repare no encodeURIComponent: sem ele, um termo com & ou # quebra a URL. E no return silencioso do AbortError, que não é falha, é o comportamento esperado. Tratar o cancelamento como erro faz a tela mostrar "Não foi possível buscar" toda vez que a pessoa digita rápido.

O texto da região de status quando não há resultado merece cuidado próprio. Ele precisa diferenciar "não achou" de "deu erro", como explica lista vazia que explica o que houve.

Testando a corrida de propósito

Em rede local tudo volta rápido e a corrida nunca aparece. Para ver o problema antes do cliente:

  1. Abra as ferramentas de desenvolvedor, aba Rede, e ative a limitação de velocidade (as opções de rede lenta do navegador).
  2. No servidor de desenvolvimento, faça termos curtos demorarem mais, por exemplo com uma espera artificial quando o termo tem menos de 4 letras.
  3. Digite rápido e confira na aba Rede: as requisições antigas devem aparecer como canceladas.
  4. Confirme que a lista final corresponde ao campo, e que nenhuma mensagem de erro apareceu.

O lado do servidor

O cliente educado não dispensa o servidor preparado. Limite o número de resultados devolvidos (20 ou 50 bastam para uma lista de sugestão), coloque um tempo máximo na consulta e responda rápido com erro em vez de segurar a conexão. O post timeout antes do infinito mostra como escolher esses limites.

Se a busca é por parte do nome em tabela grande, LIKE '%termo%' lê a tabela inteira. Para catálogos maiores, considere o índice de texto completo do seu banco ou uma ferramenta de busca dedicada, e meça antes e depois. E se a lista é paginada, leia paginação que não pula registros para a página 2 não mudar enquanto a pessoa rola.

Erros comuns

  • Só debounce, sem cancelar nem conferir. A corrida fica mais rara, aparece só em rede lenta e vira o bug que ninguém reproduz.
  • Tratar AbortError como falha. A tela mostra erro sempre que a pessoa digita rápido, exatamente quando tudo funcionou.
  • Escutar keyup em vez de input. Colar com o mouse ou usar o preenchimento automático não dispara busca.
  • Mover o foco para a lista a cada resposta. Quem digita perde o cursor no meio da palavra e precisa clicar de novo no campo.
  • Montar a URL sem encodeURIComponent. Termos com &, + ou # chegam cortados ou com outro sentido no servidor.
  • Debounce longo demais. Com um segundo de espera, a busca parece travada e a pessoa aperta Enter, que nem sempre está tratado.

Checklist

  • A busca escuta input e só dispara após uma pausa de algumas centenas de milissegundos.
  • Cada busca nova aborta a anterior com AbortController.
  • A resposta só é desenhada se o termo ainda é o do campo.
  • AbortError é ignorado sem mensagem.
  • Enter busca na hora.
  • Uma região role="status" informa a quantidade de resultados sem mover o foco.
  • O servidor limita resultados e tem tempo máximo de consulta.
  • A corrida foi testada com rede lenta simulada.

Abra a busca do seu sistema com a rede limitada e digite uma palavra longa o mais rápido que conseguir. A lista final corresponde ao que está no campo?

Avalie este artigo

Referência: https://developer.mozilla.org/docs/Web/API/AbortController