Cómo creé encuestas en vivo con Durable Objects
2026-04-04
Creé rifts.to, una herramienta de encuestas en vivo para presentadores. Escaneas un código QR, respondes una pregunta y ves cómo los resultados se actualizan en tiempo real. Sencillo en apariencia. Lo interesante es lo que lo hace funcionar por dentro, porque distribuir datos en tiempo real desde el edge es un problema que suena fácil y no lo es.
Este artículo trata sobre los Durable Objects de Cloudflare: qué son, por qué son la herramienta indicada para este problema en concreto y cómo se ve la implementación de verdad.
El problema: el tiempo real en el edge es más difícil de lo que parece
Este es el escenario. Un presentador crea una encuesta. Muestra un código QR. Cien personas en la sala lo escanean y empiezan a enviar respuestas. El panel del presentador tiene que actualizarse en tiempo real: cada respuesta debería aparecer en uno o dos segundos, sin tener que estar consultando una y otra vez.
Los Server-Sent Events (SSE) son la opción natural para esto. El panel abre una conexión HTTP persistente con el servidor, y el servidor empuja eventos por esa conexión cada vez que algo cambia. Simple, en un solo sentido, ampliamente compatible y sin la complejidad del handshake de WebSocket.
Pero hay un detalle: SSE necesita una conexión persistente y con estado a un único proceso de servidor. Cuando llega una respuesta a un nodo del edge, tienes que empujar un evento a las conexiones del panel que quizá estén abiertas en otros nodos. Con una arquitectura tradicional sin estado, resolverías esto con una capa de pub/sub: Redis, Kafka, algo por el estilo.
En el edge eso es incómodo. Los Cloudflare Workers son, por diseño, sin estado. No puedes mantener conexiones abiertas entre solicitudes. No puedes compartir memoria entre invocaciones. Eso es justo lo que los hace rápidos y baratos, y también lo que hace difícil distribuir datos en tiempo real.
Aquí entran los Durable Objects.
Qué son realmente los Durable Objects
Los Durable Objects son la respuesta de Cloudflare al cómputo con estado en el edge. Un Durable Object es una clase de JavaScript de instancia única que:
- Vive en una sola ubicación dentro de la red de Cloudflare (no se replica entre regiones)
- Tiene almacenamiento persistente mediante un almacén clave-valor incorporado
- Atiende las solicitudes de a una dentro de una misma instancia, sin problemas de concurrencia
- Puede mantener conexiones WebSocket o HTTP durante todo el tiempo que haga falta
La clave está en la garantía de instancia única. Cuando enrutas el tráfico hacia un Durable Object por su ID, cada solicitud con ese ID llega a la misma instancia. Esta es la pieza base que necesitas para la distribución: todas las conexiones SSE de una encuesta viven en el mismo Durable Object, así que cuando llega una respuesta nueva puede empujarla a todas desde un único lugar en memoria.
En rifts.to, cada encuesta tiene su propio Durable Object: un SurveyRoom. La sala mantiene todas las conexiones SSE abiertas del panel de resultados de esa encuesta.
La arquitectura
Así fluye una respuesta por el sistema:
Una persona de la audiencia envía su respuesta
↓
Cloudflare Pages (ruta edge de Next.js: /api/respond)
↓
Valida Turnstile + escribe en D1 (SQLite)
↓
Obtiene el Durable Object SurveyRoom por el ID de la encuesta
↓
Llama a /broadcast de la sala
↓
SurveyRoom empuja el evento a todas las conexiones SSE abiertas
↓
El panel del presentador se actualiza al instante
El panel abre una conexión a /api/admin/[token]/stream, una ruta edge que autentica con el token de administrador, busca la encuesta y luego delega la conexión SSE directamente al DO SurveyRoom. El DO mantiene esa conexión viva y escribe en ella cada vez que se llama a /broadcast.
Fíjate en que el stream se identifica por el token de administrador, no por el ID de la encuesta. Esto significa que solo el panel del presentador autenticado recibe el feed en vivo; la audiencia solo envía sus respuestas y ve una pantalla de agradecimiento.
El Durable Object SurveyRoom
El DO expone tres endpoints: /connect (suscribe un cliente SSE), /broadcast (empuja a todos los clientes) y /close (envía un evento final y cierra todas las conexiones). Esta es la implementación completa:
import type { SSEEvent } from "../lib/types";
export class SurveyRoom implements DurableObject {
private connections: Set<WritableStreamDefaultWriter<Uint8Array>> = new Set();
private encoder = new TextEncoder();
constructor(
private readonly state: DurableObjectState,
private readonly env: CloudflareEnv
) {}
async fetch(request: Request): Promise<Response> {
const url = new URL(request.url);
if (url.pathname === "/connect") {
return this.handleConnect(request);
}
if (url.pathname === "/broadcast" && request.method === "POST") {
return this.handleBroadcast(request);
}
if (url.pathname === "/close" && request.method === "POST") {
return this.handleClose();
}
return new Response("Not found", { status: 404 });
}
private handleConnect(request: Request): Response {
const { readable, writable } = new TransformStream<Uint8Array, Uint8Array>();
const writer = writable.getWriter();
this.connections.add(writer);
const connectedEvent: SSEEvent = { type: "connected" };
writer.write(this.encoder.encode(`data: ${JSON.stringify(connectedEvent)}\n\n`));
const cleanup = () => {
this.connections.delete(writer);
writer.close().catch(() => {});
};
request.signal.addEventListener("abort", cleanup);
return new Response(readable, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"Connection": "keep-alive",
},
});
}
private async handleBroadcast(request: Request): Promise<Response> {
const event = await request.json<SSEEvent>();
const message = this.encoder.encode(`data: ${JSON.stringify(event)}\n\n`);
const dead: WritableStreamDefaultWriter<Uint8Array>[] = [];
for (const writer of this.connections) {
try {
await writer.write(message);
} catch {
dead.push(writer);
}
}
dead.forEach((w) => this.connections.delete(w));
return new Response("ok");
}
private async handleClose(): Promise<Response> {
const event: SSEEvent = { type: "survey_closed" };
const message = this.encoder.encode(`data: ${JSON.stringify(event)}\n\n`);
for (const writer of this.connections) {
try {
await writer.write(message);
await writer.close();
} catch {}
}
this.connections.clear();
return new Response("ok");
}
}
Vale la pena destacar algunas cosas:
Ejecución de a una. Los Durable Objects atienden una solicitud a la vez dentro de una instancia. Un broadcast no compite con otro. No hacen falta locks.
Desconexión vía señal de aborto. En lugar de un bucle de heartbeat o detección de errores, la limpieza la dispara request.signal. Cuando el cliente se desconecta, se dispara el evento de aborto y el writer se quita de inmediato del conjunto. El bucle de broadcast también descarta los writers que fallan al escribir, como red de seguridad.
Endpoint /close. Cuando el presentador cierra una encuesta, la ruta de administrador llama a /close, que envía un evento survey_closed a todos los clientes y cierra las conexiones de forma limpia. Así el código del lado del cliente puede reaccionar (mostrar un mensaje de "encuesta finalizada") en lugar de simplemente perder la conexión sin más.
Evento inicial. Al conectarse, el DO escribe de inmediato un evento { type: "connected" }. Esto le confirma al cliente que el stream SSE está activo y sirve para distinguir una conexión exitosa de una colgada.
Cómo enrutar a la sala correcta
Cada encuesta recibe un ID de Durable Object derivado de su ID de encuesta. En el manejador de envíos (/api/respond):
const response = await createResponse(env.DB, surveyId, answers);
const doId = env.SURVEY_ROOM.idFromName(surveyId);
const stub = env.SURVEY_ROOM.get(doId);
await stub.fetch("http://do/broadcast", {
method: "POST",
body: JSON.stringify({ type: "new_response", response }),
headers: { "Content-Type": "application/json" },
}).catch(() => {
// No es fatal: si nadie del lado admin está mirando, el broadcast no hace nada
});
idFromName() es determinista: la misma cadena siempre se mapea a la misma instancia del DO, en cualquier punto de la red de Cloudflare. Eso es lo que te da la garantía de instancia única sin ninguna capa de coordinación.
El broadcast es de tipo "dispara y olvida" (.catch(() => {})). Si no hay ningún panel de administrador conectado, el DO no tiene writers abiertos y el broadcast no causa ningún daño.
En la ruta del stream (/api/admin/[token]/stream):
const survey = await getSurveyByAdminToken(env.DB, token);
if (!survey) {
return NextResponse.json({ error: "unauthorized" }, { status: 401 });
}
const doId = env.SURVEY_ROOM.idFromName(survey.id);
const stub = env.SURVEY_ROOM.get(doId);
return stub.fetch(new Request("http://do/connect", {
signal: request.signal,
}));
La ruta autentica y luego pasa la señal de aborto de la solicitud al DO para que pueda limpiar las conexiones cuando el navegador se va a otra página.
La base de datos: Cloudflare D1
Las definiciones de las encuestas y las respuestas viven en D1, la base de datos SQLite en el edge de Cloudflare. D1 encaja bien aquí porque las encuestas y las respuestas son relacionales, SQL es el modelo indicado para consultar resultados agregados y el esquema es lo bastante simple como para que las limitaciones de SQLite no importen.
Para el stream SSE, la consistencia eventual de D1 en las réplicas de lectura no importa: enviamos los datos de la respuesta directamente por el DO en vez de consultar D1 después de cada envío. La carga inicial de la página sí consulta D1 para traer las respuestas históricas, y eso está bien.
Hay una restricción real: D1 serializa las escrituras. Cada envío hace una lectura y una escritura en serie, y con alta concurrencia eso se vuelve el cuello de botella. En las pruebas de carga, las fallas empiezan a aparecer entre los 400 y 600 usuarios virtuales. La solución es desacoplar las escrituras a D1 del camino crítico mediante Cloudflare Queues e inserciones por lotes, pero eso es una refactorización para más adelante.
El despliegue: la parte incómoda
El adaptador next-on-pages de Cloudflare convierte una app de Next.js para desplegarla en Pages, pero los Durable Objects definidos en tu app hay que desplegarlos como un Worker aparte. El pipeline de despliegue ejecuta cuatro pasos:
# 1. Compilar Next.js y convertir al formato de Cloudflare
npx next build && npx @cloudflare/next-on-pages
# 2. Inyectar la clase del DO SurveyRoom en el worker compilado
node scripts/patch-worker.mjs
# 3. Desplegar el Worker complementario del DO
wrangler deploy --config wrangler.worker.toml
# 4. Desplegar el proyecto de Pages
wrangler pages deploy .vercel/output/static --project-name instant-survey
El script patch-worker.mjs compila SurveyRoom.ts con esbuild y antepone el resultado al bundle compilado _worker.js para que el worker de Pages pueda exportar la clase del DO. Funciona, pero es frágil: es lo primero que refactorizaría si Cloudflare mejora la integración entre Pages y los DO.
Qué funcionó y qué no
Lo que funcionó bien:
- El modelo de los DO es, de verdad, la abstracción correcta para este problema. Una vez que asimilas la garantía de enrutamiento a una instancia única, la implementación queda limpia y evidente.
- SSE sobre Durable Objects es sólido como una roca. La limpieza basada en la señal de aborto es más simple y más confiable que estar consultando heartbeats.
- D1 + DO juntos cubren toda la pila de persistencia y tiempo real sin ningún servicio externo. La app entera es nativa de Cloudflare.
Lo que haría distinto:
- El script patch-worker es un parche. Reestructurar el DO como un Worker totalmente aparte, con su propio dominio, y hacer de proxy hacia él desde la app de Pages sería más limpio.
- El rendimiento de escritura de D1 es el verdadero techo de escalabilidad. Desacoplar los envíos de las escrituras a D1 con Queues e inserciones por lotes subiría bastante ese límite.
- Habría agregado antes una lógica explícita de reconexión con
Last-Event-IDdel lado del cliente. Las conexiones SSE se caen; el navegador reconecta solo, pero con números de secuencia es fácil reproducir los eventos que se perdieron.
Pruébalo
rifts.to está en vivo y es gratis. Crea una encuesta, toma el código QR y pruébalo en tu próxima reunión de equipo o charla. No hace falta registrarse de ninguno de los dos lados.