Appearance
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:
- Polling HTTP: Frontend consulta estado cada N segundos
- Server-Sent Events (SSE): Servidor envía eventos cuando job cambia
- WebSockets: Conexión bidirectional full-duplex
- 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();