Cómo hacer polling a una API sin saturar tu servidor
Crea un sondeador de API seguro para producción: una solicitud a la vez, cancelación, espera con jitter, gestión de Retry-After, pausa en pestañas ocultas y ETag.
Un poller apto para producción envía la siguiente petición solo después de procesar la respuesta anterior, se detiene cuando el trabajo alcanza un estado terminal, aplica backoff con jitter cuando las peticiones fallan, respeta Retry-After en las respuestas 429 y 503, permanece inactivo mientras la pestaña está oculta y usa ETags para que los datos sin cambios lleguen como un 304 vacío.
La mayoría de los pollers empiezan como un setInterval con un fetch. Esa versión funciona hasta que el endpoint se vuelve lento, la API empieza a devolver errores 429 o alguien deja una pestaña abierta durante el fin de semana y el lunes sigue enviando peticiones.
Si tu servidor puede enviar actualizaciones por push, quizá no necesites polling. Compara WebSockets, SSE y long polling, o considera usar webhooks en lugar de polling en flujos entre servidores. Si el polling es la herramienta adecuada, este artículo construye un poller sin dependencias, corrigiendo un problema cada vez. Cada sección muestra primero la versión defectuosa y después la solución.
Puntos clave
- Encadenar
setTimeoutdespués de cada respuesta garantiza que solo haya una petición en curso, algo quesetIntervalno puede asegurar. - El full jitter fija cada retardo de reintento en
random(0, min(cap, base * 2 ** attempt)), de modo que los clientes que reintentan se dispersan en lugar de llegar todos a la vez a un servidor que se está recuperando. Retry-Aftertiene dos formatos, un número de segundos o una fecha HTTP, y un poller debe interpretar ambos y recurrir al backoff cuando la cabecera falta o no se puede interpretar.- Los navegadores limitan (throttling) los temporizadores en las pestañas en segundo plano, pero ese throttling solo ralentiza al poller. Lo que realmente lo detiene es comprobar
document.hidden. - Enviar el último
ETagenIf-None-Matchpermite que el servidor responda304 Not Modifiedsin cuerpo cuando nada ha cambiado.
¿Cuáles son los cuatro errores más comunes al hacer polling a una API?
El polling ingenuo a una API suele fallar de cuatro maneras: las peticiones se solapan, el polling nunca se detiene, los fallos hacen que el poller castigue aún más al servidor y las pestañas ocultas siguen haciendo polling. Los cuatro están presentes en este fragmento:
setInterval(async () => {
const res = await fetch('/api/jobs/42');
render(await res.json());
}, 2000); // overlaps when slow, never stops, ignores errors and hidden tabs
- Solapamiento.
setIntervalse dispara según un reloj fijo, no cuando llega la respuesta anterior. Si el endpoint tarda más que el intervalo, las peticiones se acumulan unas sobre otras. - Sin salida. Nada detiene el bucle cuando el trabajo termina, así que sigue solicitando un resultado que ya nunca cambiará.
- Sin backoff. Cuando el servidor devuelve errores 500, todos los clientes siguen reintentando a máxima velocidad, lo que añade carga justo cuando el servidor menos puede soportarla.
- Pestañas ocultas. Una pestaña que nadie está mirando sigue haciendo polling.
¿Cómo encadenar setTimeout y detenerse con una condición de finalización?
Si programas el siguiente poll solo después de haber procesado la respuesta actual, nunca habrá más de una petición en curso. Un bucle async con una pausa entre iteraciones consigue exactamente eso, y termina en cuanto el trabajo alcanza un 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');
La condición de finalización debe cubrir tanto el éxito como los fallos terminales. Si solo compruebas 'done', un trabajo que termina en 'failed' se consultará indefinidamente.
¿Cómo cancelar un poller con AbortController?
Cancelar un poller implica detener dos cosas: la petición en curso y el temporizador en espera. Una señal de AbortController pasada a fetch se encarga de la petición, pero no afecta a un setTimeout que ya está en cola. La función de pausa también tiene que escuchar la señal:
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 });
});
}
Llamar a controller.abort() rechaza la operación que esté pendiente, ya sea el fetch o el sleep, y el bucle termina. El listener de abort se elimina cuando se dispara el temporizador, de modo que un polling de larga duración que reutiliza una misma señal no añade un listener nuevo en cada iteración.
¿Cómo aplicar backoff exponencial con jitter y un límite máximo?
Cuando una petición falla, espera random(0, min(cap, base * 2 ** attempt)) antes de volver a intentarlo. Esta es la estrategia de “full jitter” descrita en el análisis Exponential Backoff and Jitter de AWS. Un backoff exponencial con límite pero sin aleatoriedad sigue dejando a los clientes reintentando en grupos sincronizados. Elegir cada retardo al azar entre cero y el techo exponencial rompe esos grupos.
const backoff = (attempt, base = 1000, cap = 30000) =>
Math.random() * Math.min(cap, base * 2 ** attempt);
Esta función backoff de una sola línea coincide con la función de full jitter del código fuente del simulador de backoff de AWS. En la simulación que AWS realizó en 2015 con 100 clientes compitiendo por el mismo recurso, añadir jitter redujo el número total de llamadas a menos de la mitad. Ten en cuenta que la simulación modelaba contención de escrituras, no polling de lecturas, así que toma esa cifra como el resultado de AWS para ese escenario concreto.
En el bucle, incrementa attempt después de cada fallo y restablécelo a 0 tras cualquier éxito. Sin ese reinicio, un único fallo puntual al principio hace que todos los fallos posteriores partan de un retardo largo. Los workers del lado del servidor se enfrentan al mismo problema de reintentos, y las colas de trabajos lo resuelven con políticas de reintento similares.
¿Cómo debe gestionar un poller los códigos 429, 503 y Retry-After?
Cuando el servidor envía una respuesta 429 o 503 con una cabecera Retry-After, espera el tiempo que indique la cabecera. Tu propio backoff es la alternativa para cuando la cabecera no está presente. La cabecera Retry-After adopta uno de dos formatos: un número entero de segundos que hay que esperar desde que llega la respuesta, o una fecha HTTP a partir de la cual se puede reintentar. La RFC 9110 define ambos formatos, y MDN explica su uso con 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);
}
El formato de fecha se compara con el reloj del cliente. Si los relojes no coinciden y la fecha ya ha pasado, el retardo se ajusta a cero. Si Date.parse devuelve NaN, la función devuelve null y quien la invoca usa el backoff en su lugar. El límite superior evita que una cabecera mal configurada deje el poller parado durante horas.
En peticiones cross-origin, JavaScript solo puede leer Retry-After si el servidor la incluye en Access-Control-Expose-Headers, porque no es una cabecera de respuesta CORS-safelisted. Si no está expuesta, res.headers.get() devuelve null y el poller recurre al backoff simple sin generar ningún error.
¿Cómo pausar el polling cuando la pestaña está oculta?
Los navegadores limitan los temporizadores en las pestañas en segundo plano, pero un poller limitado sigue enviando peticiones, solo que con menos frecuencia. Para detenerlo de verdad, comprueba document.hidden y escucha el evento visibilitychange, ambos parte de la Page Visibility API. Esta función auxiliar se resuelve inmediatamente cuando la página está visible y, en caso contrario, espera a que lo esté:
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 });
});
}
Si la esperas con await al principio de cada iteración, una pestaña oculta no envía ninguna petición, y la primera petición tras el regreso del usuario sale de inmediato. Una limitación: si ya hay una pausa de backoff larga en curso cuando la pestaña vuelve a ser visible, el poller espera primero a que termine.
Usa ETags y júntalo todo
Si reenvías el último ETag en una cabecera If-None-Match, el servidor puede responder 304 Not Modified sin cuerpo cuando el recurso no ha cambiado. Si estableces If-None-Match manualmente usando el modo de caché por defecto, la especificación Fetch hace que se omita la caché HTTP, por lo que tu código recibe el 304 directamente y debe gestionarlo. En peticiones cross-origin hay que tener en cuenta dos detalles de CORS. El servidor debe exponer ETag. Y como If-None-Match no es una cabecera de petición CORS-safelisted, enviarla desencadena una petición preflight, de modo que el servidor también debe incluirla en Access-Control-Allow-Headers.
Esta tabla muestra cómo reacciona el poller final ante cada resultado (el significado de los códigos de estado se encuentra en la referencia de códigos de estado HTTP enlazada más arriba):
| Respuesta | Acción del poller | Siguiente retardo |
|---|---|---|
| 200 | Actualiza la UI, comprueba la finalización, reinicia los intentos | interval |
| 304 | Sin actualización, reinicia los intentos | interval |
| 429 / 503 | Incrementa los intentos | Retry-After; si no, backoff |
| Otros 5xx / error de red | Incrementa los intentos | Backoff |
| Otros 4xx | Lanza y expone el error | Ninguno |
| Abort | Relanza el error y sale | Ninguno |
La siguiente función utiliza sleep, backoff, retryAfterMs y whenVisible de las secciones anteriores. La lógica de ramificación por código de estado está fuera del try/catch, de modo que un 4xx nunca se confunde con un error de red ni se reintenta.
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 se preincrementa, el primer reintento ya usa attempt = 1, lo que limita ese retardo a 2 segundos.
Encapsularlo en un hook de 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;
}
React ejecuta la función de limpieza del efecto al desmontar el componente y antes de volver a ejecutar el efecto cuando cambia url, así que el poller anterior siempre se aborta. Define isDone fuera del componente para que se mantenga estable. El estado 'error' existe para que la UI pueda mostrar un mensaje del tipo “Hemos dejado de comprobar. ¿Reintentar?”. Las grabaciones de sesión (session replays) de pollers que no exponen su estado muestran a menudo un spinner que nunca termina mientras las peticiones de red subyacentes se han detenido o siguen fallando.
Conclusión
Un poller que no sobrecarga tu servidor se reduce a unas pocas reglas: una petición cada vez, una condición de salida clara, backoff con jitter que se reinicia tras un éxito, respeto a Retry-After, ninguna petición desde pestañas ocultas y peticiones condicionales para que los datos sin cambios no cuesten casi nada. Sustituye tu setInterval por la función poll anterior y después comprueba que tu servidor devuelve ETag y Retry-After y que expone ambas cabeceras a los clientes cross-origin. Sin ese soporte en el servidor, el código del cliente recurre silenciosamente al backoff simple y a respuestas completas.
Preguntas frecuentes
¿Se pausa el poller cuando el usuario cambia a la ventana de otra aplicación?
No siempre. Una página pasa a estar oculta cuando su pestaña queda en segundo plano, cuando se minimiza la ventana del navegador o cuando se activa el bloqueo de pantalla del sistema operativo. Una página que sigue parcialmente visible en pantalla mantiene su visibilityState como 'visible', y perder el foco frente a otra ventana no la oculta. En ese caso, el poller sigue ejecutándose. Escucha los eventos blur y focus si también quieres pausarlo cuando la ventana pierde el foco.
¿Qué diferencia hay entre full jitter, equal jitter y decorrelated jitter?
Cada uno aleatoriza el backoff exponencial con límite de una forma distinta. El full jitter elige un retardo entre cero y min(cap, base * 2 ** attempt). El equal jitter mantiene fija la mitad de ese techo y aleatoriza la otra mitad. El decorrelated jitter elige un retardo entre base y el triple del retardo anterior, con límite. En la simulación de AWS, el equal jitter fue el más débil de los tres enfoques con jitter, y el full jitter realizó menos trabajo que el decorrelated jitter, aunque tardó algo más.
¿Puedo usar este poller en Node.js?
Sí, con un cambio. Node.js 18 y versiones posteriores ofrecen un fetch global, que pasó a ser estable en Node.js 21, y un AbortController global. Node.js no tiene objeto document, así que whenVisible lanza un ReferenceError. Añade una comprobación typeof document que se resuelva de inmediato fuera del navegador. El backoff y la gestión de Retry-After y ETag funcionan sin cambios. Node.js no aplica CORS, por lo que todas las cabeceras de respuesta son legibles.
¿Por qué fetch nunca devuelve un 304 si no establezco If-None-Match yo mismo?
La caché HTTP del navegador gestiona la revalidación internamente. Cuando una respuesta almacenada necesita revalidarse, el propio navegador añade las cabeceras condicionales. Si el servidor responde con un 304, fetch entrega a tu código la respuesta en caché con un estado 200 y el cuerpo completo, de modo que no puedes saber que la petición era condicional. Establecer If-None-Match manualmente es lo que hace que el 304 sea visible para JavaScript.