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
- Escute o evento
inputdo campo, nãokeyup, para pegar colar, apagar e preenchimento automático. - Ignore termos curtos demais (menos de 2 caracteres, por exemplo) e limpe a lista quando o campo ficar vazio.
- Reinicie um temporizador a cada evento e só busque quando ele disparar.
- Aborte a requisição anterior e crie um
AbortControllernovo para a atual. - Antes de desenhar, confira se o termo ainda é o do campo.
- Atualize uma região de status com a quantidade de resultados, sem mexer no foco.
- 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:
- Abra as ferramentas de desenvolvedor, aba Rede, e ative a limitação de velocidade (as opções de rede lenta do navegador).
- No servidor de desenvolvimento, faça termos curtos demorarem mais, por exemplo com uma espera artificial quando o termo tem menos de 4 letras.
- Digite rápido e confira na aba Rede: as requisições antigas devem aparecer como canceladas.
- 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
AbortErrorcomo falha. A tela mostra erro sempre que a pessoa digita rápido, exatamente quando tudo funcionou. - Escutar
keyupem vez deinput. 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
inpute 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?
Referência: https://developer.mozilla.org/docs/Web/API/AbortController