Skip to content

ADR-003: Polling HTTP → SSE (Fases)

Fecha: 2026-02-05 Estado: Aprobado (Fase 1 implementándose) Deciders: Architecture Team, Frontend Team

Contexto y Problema

Usuario necesita saber cuando su job termina (success/error). Opciones de notificación:

  1. Polling HTTP: Frontend consulta estado cada N segundos
  2. Server-Sent Events (SSE): Servidor envía eventos cuando job cambia
  3. WebSockets: Conexión bidirectional full-duplex
  4. Push Notifications: Service worker + Push API

Criterios de evaluación:

  • Latencia de notificación (tiempo entre job completo → usuario notificado)
  • Complejidad de implementación
  • Overhead de servidor/red
  • Compatibilidad con navegadores
  • Infraestructura necesaria

Opciones Consideradas

Opción A: Polling HTTP (MVP - FASE 1)

Descripción:

  • Frontend hace setInterval(() => fetch('/api/jobs/${id}'), 2000)
  • Backend retorna estado actual del job
  • Cuando status='completed' o 'failed', frontend detiene polling

Pros:

  • ✅ Muy simple de implementar (días)
  • ✅ CERO infraestructura adicional
  • ✅ Funciona en 100% de navegadores
  • ✅ NO requiere conexión persistente
  • ✅ Compatible con todos los proxies/load balancers

Contras:

  • ❌ Latencia: 2-5 segundos (intervalo de polling)
  • ❌ Overhead: requests aunque job no haya cambiado
  • ❌ No escala bien con muchos jobs concurrentes (N usuarios = N polling loops)

Casos de uso:

  • ✅ MVP (Fase 1)
  • ✅ Jobs de larga duración (> 1 minuto) donde latencia de 2-5s es aceptable
  • ✅ Volumen bajo (< 50 jobs concurrentes)

Opción B: Server-Sent Events (SSE - FASE 2)

Descripción:

  • Backend: Trigger PostgreSQL AFTER UPDATE en background_jobs
  • Trigger envía: NOTIFY job_updates_{id}, '{"status":"completed"}'
  • Controller escucha: LISTEN job_updates_{id}
  • Controller envía evento SSE al frontend
  • Frontend: new EventSource('/api/jobs/${id}/stream')

Pros:

  • ✅ Latencia mínima: 50-200ms
  • ✅ CERO overhead cuando job no cambia (conexión idle)
  • ✅ Protocolo simple (HTTP streaming)
  • ✅ Reconexión automática (built-in en EventSource)
  • ✅ Sin dependencias externas (PostgreSQL nativo)

Contras:

  • ❌ 1 conexión persistente por usuario activo
  • ❌ EventSource NO soporta custom headers (workaround: auth via query param)
  • ❌ Límite de 6 conexiones por dominio en HTTP/1.1
  • ❌ Más complejo que polling (días vs semanas)

Casos de uso:

  • ✅ UX crítica (usuarios no toleran espera de polling)
  • ✅ Volumen medio (50-500 jobs concurrentes)
  • ✅ Jobs de cualquier duración (notificación instantánea)

Opción C: WebSockets

Descripción:

  • Full-duplex bidirectional connection
  • Servidor envía eventos cuando job cambia
  • Cliente puede enviar mensajes también

Pros:

  • ✅ Latencia mínima: 10-50ms
  • ✅ Bidirectional (si se necesita en futuro)
  • ✅ Binary data support

Contras:

  • ❌ Protocolo más complejo (handshake, framing, ping/pong)
  • ❌ Requiere servidor WS dedicado o librería (Ratchet, Swoole)
  • ❌ Proxies pueden bloquear conexiones (requiere wss://)
  • ❌ NO hay reconexión automática (debe implementarse)
  • ❌ Overkill para unidirectional notifications

Veredicto: ❌ Descartado (complejidad sin beneficio vs SSE)


Opción D: Push Notifications

Descripción:

  • Service worker + Push API
  • Servidor envía push notification cuando job completa
  • Usuario recibe notificación aunque navegador cerrado

Pros:

  • ✅ Funciona aunque usuario cierre navegador
  • ✅ Notificación nativa del OS

Contras:

  • ❌ Requiere HTTPS
  • ❌ Requiere permiso del usuario (muchos lo deniegan)
  • ❌ Complejo de implementar (service worker, VAPID keys, etc.)
  • ❌ NO funciona en desktop si browser cerrado

Veredicto: ❌ Descartado (complejidad excesiva, NO cubre caso desktop)


Decisión

Implementar en FASES:

Fase 1 (MVP - 2-3 semanas): Polling HTTP Fase 2 (Optimización - 1-2 semanas): SSE + PostgreSQL NOTIFY

Justificación:

  • Time to market: Polling permite MVP funcional rápido (2-3 semanas)
  • Progressive enhancement: SSE mejora UX sin breaking changes
  • Path claro: Frontend abstrae notificación (polling o SSE), backend solo agrega endpoint SSE

Consecuencias

Fase 1: Polling HTTP

Positivas:

  • ✅ MVP funcional en 2-3 semanas
  • ✅ CERO complejidad adicional
  • ✅ Latencia aceptable para jobs largos (> 1 minuto)

Negativas:

  • ❌ Latencia 2-5 segundos
  • ❌ Overhead de requests constantes

Fase 2: SSE + NOTIFY

Positivas:

  • ✅ Latencia 50-200ms (near real-time)
  • ✅ CERO overhead cuando job no cambia
  • ✅ Sin dependencias externas (PostgreSQL nativo)

Negativas:

  • ❌ 1 conexión persistente por usuario activo
  • ❌ Complejidad adicional (trigger, LISTEN, SSE)

Implementación

Fase 1: Polling

Frontend (React):

javascript
function useJobStatus(jobId) {
  return useQuery({
    queryKey: ['job', jobId],
    queryFn: () => api.get(`/api/jobs/${jobId}`),
    refetchInterval: (data) => {
      if (data?.data?.status === 'pending' || data?.data?.status === 'running') {
        return 2000; // Poll cada 2 segundos
      }
      return false; // Stop polling si completed/failed
    }
  });
}

Fase 2: SSE

Backend (PHP):

php
public function stream(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
{
    $jobId = (int) $args['id'];

    // Headers SSE
    $response = $response
        ->withHeader('Content-Type', 'text/event-stream')
        ->withHeader('Cache-Control', 'no-cache');

    // LISTEN PostgreSQL
    $pdo->exec("LISTEN job_updates_{$jobId}");

    while (true) {
        $notification = pg_get_notify($pdo);
        if ($notification) {
            $response->getBody()->write("data: {$notification['payload']}\n\n");
            flush();

            $data = json_decode($notification['payload']);
            if (in_array($data['status'], ['completed', 'failed'])) {
                break; // Cerrar stream
            }
        }
        usleep(100000); // 100ms
    }

    return $response;
}

Frontend (React):

javascript
function useJobStatusSSE(jobId) {
  const [status, setStatus] = useState('pending');

  useEffect(() => {
    const eventSource = new EventSource(`/api/jobs/${jobId}/stream?token=${getToken()}`);

    eventSource.addEventListener('message', (event) => {
      const data = JSON.parse(event.data);
      setStatus(data.status);

      if (data.status === 'completed' || data.status === 'failed') {
        eventSource.close();
      }
    });

    return () => eventSource.close();
  }, [jobId]);

  return status;
}

Migration (PostgreSQL):

sql
CREATE OR REPLACE FUNCTION notify_job_update()
RETURNS TRIGGER AS $$
BEGIN
  PERFORM pg_notify(
    'job_updates_' || NEW.id,
    json_build_object(
      'id', NEW.id,
      'status', NEW.status,
      'result', NEW.result,
      'error', NEW.error
    )::text
  );
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER background_jobs_update_trigger
AFTER UPDATE ON background_jobs
FOR EACH ROW
WHEN (OLD.status IS DISTINCT FROM NEW.status)
EXECUTE FUNCTION notify_job_update();

Referencias