planeta jupiter planeta tierra

Polling: cuando sigue siendo buena decisión usarlo

· 6 min de lectura · Arquitectura

Dos flechas en círculo sobre la palabra polling
nave extraterrestre
Dos flechas en círculo sobre la palabra polling

El polling tiene mala fama: suena a chapuza al lado de WebSockets o Mercure. Pero en muchos casos sigue siendo la opción más sencilla y la que mejor funciona. Te explico qué es, cuándo usarlo y qué alternativas hay cuando no encaja.

Explicación

Hacer polling es que el navegador pregunte al servidor cada cierto tiempo: «¿hay algo nuevo?». El servidor responde con lo que tenga y, pasados unos segundos, el navegador vuelve a preguntar.

async function checkStatus() {
    const response = await fetch('/reports/42/status');
    const data = await response.json();

    if (data.finished) {
        window.location = data.url; // ya está: dejamos de preguntar
        return;
    }

    setTimeout(checkStatus, 3000); // si no, volvemos a preguntar en 3 segundos
}

checkStatus();

Y en el servidor, una ruta pequeña que solo devuelve el estado:

#[Route('/reports/{id}/status')]
public function status(Report $report): JsonResponse
{
    return $this->json([
        'finished' => $report->isFinished(),
        'url'      => $report->getUrl(),
    ]);
}

Eso es todo: peticiones HTTP normales. Sin conexiones abiertas, sin servidores especiales y sin librerías.

Otro ejemplo: comentarios nuevos

Una página muestra los comentarios de un post y queremos que los nuevos aparezcan solos, sin recargar. La idea: el navegador recuerda el id del último comentario que tiene y pregunta «¿hay alguno después de este?».

Al pintar la página, guardamos ese último id en la lista:

<ul id="comments" data-last-id="{{ comments is empty ? 0 : (comments|last).id }}">
    {% for comment in comments %}
        <li>{{ comment.author }}: {{ comment.text }}</li>
    {% endfor %}
</ul>

La ruta devuelve solo los comentarios con un id mayor. Si no hay ninguno, una lista vacía:

#[Route('/posts/{id}/comments')]
public function newComments(Post $post, Request $request, CommentRepository $repository): JsonResponse
{
    $after = $request->query->getInt('after');

    $comments = $repository->createQueryBuilder('c')
        ->where('c.post = :post AND c.id > :after')
        ->setParameter('post', $post)
        ->setParameter('after', $after)
        ->orderBy('c.id')
        ->getQuery()
        ->getResult();

    return $this->json(array_map(fn (Comment $c) => [
        'id'     => $c->getId(),
        'author' => $c->getAuthor(),
        'text'   => $c->getText(),
    ], $comments));
}

Y el JavaScript pregunta cada 10 segundos y añade al final lo que llegue:

const list = document.querySelector('#comments');
let lastId = Number(list.dataset.lastId);

async function checkComments() {
    try {
        const response = await fetch(`/posts/7/comments?after=${lastId}`);
        const newComments = await response.json();

        for (const comment of newComments) {
            const li = document.createElement('li');
            li.textContent = `${comment.author}: ${comment.text}`;
            list.append(li);

            lastId = comment.id; // el siguiente fetch pedirá a partir de aquí
        }
    } finally {
        setTimeout(checkComments, 10000); // aunque falle, volvemos a preguntar
    }
}

setTimeout(checkComments, 10000);

Dos detalles:

  • El finally. Si un día falla la petición (se corta la wifi un momento), sin él no se volvería a preguntar nunca más. Con él, el bucle sigue pase lo que pase.
  • Una sola pregunta, no dos. Podríamos preguntar primero «¿hay nuevos?» y luego «¿cuáles?», pero no hace falta: si no hay nada, la lista vacía cuesta lo mismo que un «no». Y si los hay, te ahorras una petición.
  • textContent, no innerHTML. El texto lo ha escrito un usuario: con innerHTML, un comentario con <script> se ejecutaría en la página de todos los lectores.

Cuándo sigue siendo mejor usarlo

  • Esperar a que termine una tarea. El usuario pide un informe, lo mandas a una cola con Messenger y le enseñas un «Generando…». Preguntar cada pocos segundos hasta que esté listo es perfecto.
  • Datos que cambian poco. El estado de un pedido o las notificaciones sin leer. Si da igual enterarse unos segundos tarde, no hace falta más.
  • Tu servidor es PHP normal. Con PHP-FPM, cada conexión que se queda abierta ocupa un proceso entero. Con polling, cada petición dura milisegundos y el proceso queda libre enseguida.
  • Una API externa que no avisa. Si el proveedor no tiene webhooks, no queda otra que preguntarle tú cada cierto tiempo (con un comando en cron).
  • Pocos usuarios. Un panel interno que usan cinco personas puede refrescar cada 10 segundos sin que nadie lo note.

Para saber si tu servidor lo aguanta, una cuenta rápida: usuarios conectados ÷ segundos entre preguntas = peticiones por segundo. 100 usuarios cada 5 segundos son 20 peticiones por segundo: nada.

Las alternativas

Cuando el polling se queda corto, estas son las opciones. Todas tienen algo en común: mantienen una conexión abierta con cada usuario, y eso necesita más infraestructura.

Alternativa Cómo funciona Úsala para
Long polling El servidor no responde hasta que hay algo nuevo. Casi nada hoy: SSE hace lo mismo mejor.
SSE / Mercure El servidor envía datos cuando quiere por una conexión abierta. Avisos al instante a muchos usuarios.
WebSockets Conexión abierta en los dos sentidos. Chats, juegos, edición colaborativa.

En Symfony, lo habitual para tiempo real es Mercure: un servidor aparte que mantiene las conexiones, para que PHP no tenga que hacerlo. Funciona muy bien, pero es una pieza más que desplegar y mantener.

Si usas polling, hazlo bien

  • setTimeout, no setInterval, aunque preguntes siempre. Si una respuesta tarda más que el intervalo, setInterval lanza la siguiente petición igualmente. En los comentarios, eso son dos peticiones con el mismo lastId y el mismo comentario pintado dos veces. Con setTimeout al final, solo preguntas cuando ha llegado la respuesta anterior.
  • Que un error no pare el bucle. Pon el setTimeout en un finally, como en el ejemplo de los comentarios.
  • Si la espera tiene final, para. Como el return del primer ejemplo.
  • Pregunta menos si la pestaña está oculta. document.hidden ? 30000 : 3000.
  • Que la ruta sea rápida. Devuelve un JSON pequeño, no una página entera.
  • Protégela con un rate limiter. Una ruta que se llama en bucle es justo la que conviene limitar.

Y ya está

El polling no es una solución de segunda: es la más simple que funciona. Antes de montar un servidor de tiempo real, pregúntate cuántos segundos de retraso importan de verdad. Si la respuesta es «unos cuantos», te basta con un setTimeout y una ruta pequeña.

← Volver al blog

Mi hija de 11 años me ayudó a crear el diseño de este portfolio.