Como fazer polling de uma API sem sobrecarregar seu servidor
Crie um poller de API seguro para produção, com uma solicitação por vez, cancelamento, espera com jitter, Retry-After, pausa em abas ocultas e cache ETag.
Um poller de API seguro para produção envia a próxima requisição somente depois que a resposta anterior foi processada, para quando o job atinge um estado terminal, aplica backoff com jitter quando as requisições falham, respeita o Retry-After em respostas 429 e 503, fica inativo enquanto a aba está oculta e usa ETags para que dados inalterados retornem como um 304 vazio.
A maioria dos pollers começa como setInterval mais fetch. Essa versão funciona até que o endpoint fique lento, a API comece a retornar 429 ou alguém deixe uma aba aberta durante o fim de semana e ela continue enviando requisições na segunda-feira.
Se o seu servidor consegue enviar atualizações por push, talvez você nem precise de polling. Compare WebSockets, SSE e long polling ou considere webhooks em vez de polling para fluxos entre servidores. Se o polling for a ferramenta certa, este artigo constrói um poller sem dependências, uma correção de cada vez. Cada seção mostra primeiro a versão com problema e depois a correção.
Principais conclusões
- Encadear um
setTimeoutapós cada resposta garante que haja apenas uma requisição em andamento por vez, algo que osetIntervalnão consegue garantir. - O full jitter define cada atraso de retentativa como
random(0, min(cap, base * 2 ** attempt)), de modo que os clientes que fazem retentativas se espalham no tempo em vez de chegarem todos de uma vez a um servidor em recuperação. - O
Retry-Afteraparece em duas formas, delay-seconds ou uma data HTTP, e um poller precisa interpretar ambas e recorrer ao backoff quando o header estiver ausente ou não puder ser interpretado. - Os navegadores aplicam throttling aos timers em abas em segundo plano, mas o throttling apenas desacelera o poller. Verificar
document.hiddené o que realmente o interrompe. - Enviar o último
ETagemIf-None-Matchpermite que o servidor responda304 Not Modifiedsem corpo quando nada mudou.
Quais são os quatro erros mais comuns no polling de APIs?
O polling ingênuo de APIs costuma falhar de quatro formas: as requisições se sobrepõem, o polling nunca para, as falhas fazem o poller pressionar ainda mais o servidor e abas ocultas continuam fazendo polling. Os quatro problemas estão presentes neste trecho:
setInterval(async () => {
const res = await fetch('/api/jobs/42');
render(await res.json());
}, 2000); // overlaps when slow, never stops, ignores errors and hidden tabs
- Sobreposição. O
setIntervaldispara em um relógio fixo, e não depois que a resposta anterior chega. Quando o endpoint demora mais que o intervalo, as requisições se acumulam umas sobre as outras. - Sem condição de saída. Nada interrompe o loop depois que o job termina, então ele continua buscando um resultado que nunca vai mudar.
- Sem backoff. Quando o servidor retorna 500, todos os clientes continuam tentando novamente a toda velocidade, o que aumenta a carga justamente no momento em que o servidor menos consegue lidar com ela.
- Abas ocultas. Uma aba que ninguém está olhando continua fazendo polling.
Como encadear setTimeout e parar em uma condição de conclusão?
Se você agendar o próximo poll somente depois que a resposta atual for processada, nunca haverá mais de uma requisição em andamento. Um loop async com um sleep entre as iterações faz exatamente isso, e ele termina assim que o job atinge um estado terminal:
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function pollJob(url, isDone, interval = 2000) {
while (true) {
const res = await fetch(url);
const data = await res.json();
render(data);
if (isDone(data)) return data;
await sleep(interval);
}
}
pollJob('/api/jobs/42', (job) => job.status === 'done' || job.status === 'failed');
A condição de conclusão deve cobrir tanto as falhas terminais quanto o sucesso. Se você verificar apenas 'done', um job que termina em 'failed' será consultado para sempre.
Como cancelar um poller com AbortController?
Cancelar um poller significa interromper duas coisas: a requisição em andamento e o timer que está aguardando. Um signal de AbortController passado ao fetch cuida da requisição, mas não tem efeito sobre um setTimeout já enfileirado. O sleep também precisa escutar o signal:
function sleep(ms, signal) {
return new Promise((resolve, reject) => {
if (signal?.aborted) return reject(signal.reason);
const onAbort = () => {
clearTimeout(id);
reject(signal.reason);
};
const id = setTimeout(() => {
signal?.removeEventListener('abort', onAbort);
resolve();
}, ms);
signal?.addEventListener('abort', onAbort, { once: true });
});
}
Chamar controller.abort() rejeita a operação que estiver pendente, seja o fetch ou o sleep, e o loop termina. O listener de abort é removido quando o timer dispara, então um polling de longa duração que reutiliza o mesmo signal não adiciona um novo listener a cada iteração.
Como aplicar backoff exponencial com jitter e um teto?
Quando uma requisição falhar, aguarde random(0, min(cap, base * 2 ** attempt)) antes de tentar novamente. Essa é a estratégia de “full jitter” da análise Exponential Backoff and Jitter da AWS. Um backoff exponencial com teto, mas sem aleatoriedade, ainda deixa os clientes fazendo retentativas em grupos sincronizados. Escolher cada atraso aleatoriamente entre zero e o teto exponencial desfaz esses agrupamentos.
const backoff = (attempt, base = 1000, cap = 30000) =>
Math.random() * Math.min(cap, base * 2 ** attempt);
Essa função backoff de uma linha corresponde à função de full jitter no código-fonte do simulador de backoff da AWS. Na simulação de 2015 da AWS, com 100 clientes disputando o mesmo recurso, adicionar jitter reduziu o número total de chamadas em mais da metade. Observe que a simulação modelou contenção de escrita, e não polling de leitura, então trate esse número como o resultado da AWS para aquele cenário específico.
No loop, incremente attempt após cada falha e redefina-o para 0 após qualquer sucesso. Sem essa redefinição, uma única instabilidade no início faz com que todas as falhas posteriores comecem com um atraso longo. Workers no lado do servidor enfrentam o mesmo problema de retentativas, e filas de jobs lidam com ele usando políticas de retry semelhantes.
Como um poller deve lidar com 429, 503 e Retry-After?
Quando o servidor enviar uma resposta 429 ou 503 com um header Retry-After, aguarde o tempo indicado pelo header. O seu próprio backoff serve como fallback para quando o header estiver ausente. O header Retry-After assume uma de duas formas: um número inteiro de segundos a aguardar após a chegada da resposta ou uma data HTTP a partir da qual é possível tentar novamente. A RFC 9110 define as duas formas, e o MDN aborda seu uso com 429 Too Many Requests.
const MAX_RETRY_AFTER = 5 * 60_000; // design choice, not a spec rule
function retryAfterMs(res) {
const value = res.headers.get('Retry-After');
if (!value) return null;
if (/^\d+$/.test(value.trim())) return Math.min(Number(value) * 1000, MAX_RETRY_AFTER);
const date = Date.parse(value);
if (Number.isNaN(date)) return null;
return Math.min(Math.max(0, date - Date.now()), MAX_RETRY_AFTER);
}
A forma com data é comparada com o relógio do cliente. Se os relógios divergirem e a data já estiver no passado, o atraso é limitado a zero. Se Date.parse retornar NaN, a função retorna null e quem a chamou usa o backoff no lugar. O limite superior impede que um header mal configurado deixe o poller parado por horas.
Em requisições cross-origin, o JavaScript só consegue ler o Retry-After se o servidor o listar em Access-Control-Expose-Headers, porque ele não é um header de resposta CORS-safelisted. Se ele não estiver exposto, res.headers.get() retorna null e o poller recorre silenciosamente ao backoff simples, sem nenhum erro.
Como pausar o polling quando a aba está oculta?
Os navegadores aplicam throttling aos timers em abas em segundo plano, mas um poller com throttling continua enviando requisições, apenas com menos frequência. Para realmente interrompê-lo, verifique document.hidden e escute o evento visibilitychange, ambos parte da Page Visibility API. Este helper é resolvido imediatamente quando a página está visível e, caso contrário, aguarda até que ela fique visível:
function whenVisible(signal) {
if (signal?.aborted) return Promise.reject(signal.reason);
if (!document.hidden) return Promise.resolve();
return new Promise((resolve, reject) => {
const done = () => {
document.removeEventListener('visibilitychange', onChange);
signal?.removeEventListener('abort', onAbort);
};
const onChange = () => { if (!document.hidden) { done(); resolve(); } };
const onAbort = () => { done(); reject(signal.reason); };
document.addEventListener('visibilitychange', onChange);
signal?.addEventListener('abort', onAbort, { once: true });
});
}
Se você aguardá-lo (com await) no início de cada iteração, uma aba oculta não envia nenhuma requisição, e a primeira requisição após o retorno do usuário é disparada imediatamente. Uma limitação: se um sleep de backoff longo já estiver em execução quando a aba ficar visível, o poller espera que ele termine primeiro.
Use ETags e junte tudo
Se você enviar o último ETag de volta em um header If-None-Match, o servidor pode responder 304 Not Modified sem corpo quando o recurso não tiver mudado. Definir o If-None-Match manualmente usando o modo de cache padrão faz com que a especificação Fetch ignore o cache HTTP, de modo que seu código recebe o 304 diretamente e precisa tratá-lo. Dois detalhes de CORS se aplicam a requisições cross-origin. O servidor precisa expor o ETag. E, como o If-None-Match não é um header de requisição CORS-safelisted, enviá-lo dispara um preflight, então o servidor também precisa listá-lo em Access-Control-Allow-Headers.
Esta tabela mostra como o poller finalizado reage a cada resultado (o significado dos códigos de status está na referência de códigos de status HTTP mencionada acima):
| Resposta | Ação do poller | Próximo atraso |
|---|---|---|
| 200 | Atualiza a UI, verifica a conclusão, zera as tentativas | interval |
| 304 | Sem atualização, zera as tentativas | interval |
| 429 / 503 | Incrementa as tentativas | Retry-After, senão backoff |
| Outros 5xx / erro de rede | Incrementa as tentativas | Backoff |
| Outros 4xx | Lança e expõe o erro | Nenhum |
| Abort | Relança e encerra | Nenhum |
A função abaixo usa sleep, backoff, retryAfterMs e whenVisible das seções anteriores. A ramificação por status fica fora do try/catch, para que um 4xx nunca seja confundido com um erro de rede e acabe sendo retentado.
async function poll(url, { isDone = () => false, onUpdate, signal, interval = 2000, maxAttempts = 8 } = {}) {
let etag = null;
let attempt = 0;
while (true) {
await whenVisible(signal);
let res = null;
try {
res = await fetch(url, { signal, headers: etag ? { 'If-None-Match': etag } : {} });
} catch (err) {
if (signal?.aborted) throw err; // otherwise: network error, res stays null
}
let wait = interval;
if (res?.status === 304) {
attempt = 0; // no body: never call res.json() here
} else if (res?.ok) {
attempt = 0;
etag = res.headers.get('ETag'); // null if absent or not exposed
const data = await res.json();
onUpdate?.(data);
if (isDone(data)) return data;
} else if (!res || res.status === 429 || res.status >= 500) {
if (++attempt > maxAttempts) throw new Error('Polling gave up');
wait = (res && retryAfterMs(res)) ?? backoff(attempt);
} else {
throw new Error(`Polling failed: HTTP ${res.status}`);
}
await sleep(wait, signal);
}
}
Como attempt é pré-incrementado, a primeira retentativa já usa attempt = 1, o que limita esse atraso a 2 segundos.
Encapsulando em um hook do React
import { useEffect, useState } from 'react';
function usePoll(url, isDone) {
const [state, setState] = useState({ status: 'polling', data: null });
useEffect(() => {
const controller = new AbortController();
poll(url, { isDone, signal: controller.signal, onUpdate: (data) => setState({ status: 'polling', data }) })
.then((data) => setState({ status: 'done', data }))
.catch((error) => { if (!controller.signal.aborted) setState({ status: 'error', error }); });
return () => controller.abort();
}, [url]);
return state;
}
O React executa o cleanup do effect na desmontagem e antes de reexecutar o effect quando url muda, então o poller antigo é sempre abortado. Defina isDone fora do componente para que ele permaneça estável. O status 'error' existe para que a UI possa renderizar um estado do tipo “Paramos de verificar. Tentar novamente?”. Session replays de pollers que não expõem seu estado frequentemente mostram um spinner que nunca termina, enquanto as requisições de rede por trás dele já pararam ou continuam falhando.
Conclusão
Um poller que não sobrecarrega seu servidor se resume a algumas regras: uma requisição por vez, uma condição de saída clara, backoff com jitter que é zerado após um sucesso, respeito ao Retry-After, nenhuma requisição a partir de abas ocultas e requisições condicionais, para que dados inalterados custem quase nada. Substitua seu setInterval pela função poll acima e, em seguida, verifique se o seu servidor retorna ETag e Retry-After e expõe ambos para clientes cross-origin. Sem esse suporte no lado do servidor, o código do cliente recorre silenciosamente ao backoff simples e a respostas completas.
Perguntas frequentes
O poller pausa quando o usuário alterna para a janela de outro aplicativo?
Nem sempre. Uma página fica oculta quando sua aba vai para segundo plano, quando a janela do navegador é minimizada ou quando a tela de bloqueio do sistema operacional é ativada. Uma página que permanece parcialmente visível na tela mantém seu visibilityState como 'visible', e perder o foco para outra janela não a torna oculta. Nesse caso, o poller continua em execução. Escute os eventos blur e focus se você também quiser pausar quando a janela perder o foco.
Qual é a diferença entre full jitter, equal jitter e decorrelated jitter?
Cada um aplica aleatoriedade ao backoff exponencial com teto de uma forma diferente. O full jitter escolhe um atraso entre zero e min(cap, base * 2 ** attempt). O equal jitter mantém metade desse teto fixa e torna aleatória a outra metade. O decorrelated jitter escolhe um atraso entre base e três vezes o atraso anterior, com teto. Na simulação da AWS, o equal jitter foi a mais fraca das três abordagens com jitter, e o full jitter gerou menos trabalho que o decorrelated jitter, mas levou um pouco mais de tempo.
Posso usar este poller no Node.js?
Sim, com uma alteração. O Node.js 18 e versões posteriores oferecem um fetch global, que se tornou estável no Node.js 21, e um AbortController global. O Node.js não tem o objeto document, então whenVisible lança um ReferenceError. Adicione uma verificação com typeof document que resolva imediatamente fora do navegador. O backoff e o tratamento de Retry-After e de ETag funcionam sem alterações. O Node.js não aplica CORS, então todos os headers de resposta podem ser lidos.
Por que o fetch nunca retorna 304 quando eu mesmo não defino o If-None-Match?
O cache HTTP do navegador cuida da revalidação internamente. Quando uma resposta armazenada precisa ser revalidada, o próprio navegador adiciona os headers condicionais. Se o servidor responder 304, o fetch entrega ao seu código a resposta em cache com status 200 e o corpo completo, então não é possível saber que a requisição foi condicional. É definir o If-None-Match manualmente que torna o 304 visível para o JavaScript.