Cómo reducir en un 90 % el costo de servidores en tiempo real con la WebSocket Hibernation API de Durable Objects

Si alguna vez has montado una sala de chat en tiempo real o una sala de juego multijugador con Durable Objects (DO), es probable que hayas visto algo raro en la factura: el costo de Duration (GB-s) se acumula casi igual durante la madrugada, cuando apenas circulan mensajes, que durante las horas de mayor actividad. El culpable, en la inmensa mayoría de los casos, es código WebSocket estándar escrito con la combinación server.accept() + addEventListener.
Resumen clave
- El cobro de Duration (GB-s) de Durable Objects se calcula según el tiempo de reloj (wall-clock time) que el DO permanece residente en memoria, sin importar si realmente está haciendo algo o no.
- La API de WebSocket estándar (
ws.accept()+addEventListener) mantiene al DO en memoria mientras haya al menos una conexión abierta, así que el cargo se sigue acumulando tal cual incluso en tiempo inactivo. - La WebSocket Hibernation API (
ctx.acceptWebSocket()+webSocketMessage/webSocketClose/webSocketError) mantiene la conexión del cliente en el edge de la red de Cloudflare mientras descarga de la memoria la instancia del DO. Durante la hibernación no se genera ningún cargo de Duration (GB-s). - Más abajo, al aplicar directamente la tarifa oficial (según la documentación de developers.cloudflare.com de julio de 2026), la simulación muestra que, al operar durante un mes 2.000 salas de chat/juego con una “actividad normal” (alrededor de 1 mensaje por segundo), el costo total se reduce en aproximadamente un 90,6 %. El ahorro real varía mucho según el patrón de tráfico, entre el 49 % y el 98 %.
El origen del problema: por qué un WebSocket “residente” cuesta dinero incluso en tiempo inactivo
La documentación oficial de precios de Cloudflare define así el cobro de Duration de Durable Objects: Duration se factura en tiempo de reloj mientras el Object está activo, o inactivo pero no apto para hibernación, y se calcula sobre la base de los 128MB de memoria asignados al DO, sin importar el uso real de memoria. En el plan Workers Paid se incluyen 400.000 GB-s gratis al mes, y el excedente cuesta $12,50 por cada millón de GB-s.
Aquí está la trampa clave: la definición de “estado activo”. En el patrón estándar —crear un new WebSocketPair() dentro del handler fetch(), llamar a server.accept() y recibir mensajes con server.addEventListener("message", ...)— el DO tiene que mantener el listener de eventos de JS vivo en memoria para poder recibir el próximo evento. Es decir, mientras haya aunque sea un solo cliente conectado, la instancia del DO no puede ser desalojada (evicted) ni siquiera en los momentos en que no circula ningún mensaje, y todo ese tiempo se factura como GB-s.
Esto es un eje completamente distinto al cobro por tiempo de CPU de Workers. El tiempo de CPU del plan Workers Paid (30 millones de ms al mes incluidos, $0,02 por cada millón de ms adicional) solo cobra por el tiempo en que el código realmente se ejecuta, pero la Duration de un DO se factura incluso cuando no se ejecuta ningún código y simplemente está esperando. A las 3 de la madrugada, aunque nadie escriba en el chat, o aunque haya un usuario conectado al lobby del juego que se alejó del teclado, el DO tiene que seguir vivo, así que el cobro continúa.
Qué hace exactamente la Hibernation API
La WebSocket Hibernation API invierte esta estructura. Según la documentación oficial, cuando el DO queda inactivo se lo “desaloja de la memoria”, pero “los clientes WebSocket permanecen conectados a la red de Cloudflare”. Desde el punto de vista del cliente, la conexión nunca se cortó, y el ping/pong sigue funcionando con normalidad. Cuando llega un nuevo evento (un mensaje, un cierre de conexión, etc.), el runtime vuelve a invocar el constructor para recrear la instancia del DO, procesa ese evento y lo vuelve a dormir.
Desde el punto de vista del cobro, la frase más importante es esta: “durante la hibernación no se genera ningún cargo de Duration (GB-s)”. Es decir, el GB-s solo se acumula durante el breve instante en que el DO procesa realmente un mensaje, y el resto del tiempo —la inmensa mayoría— es gratis.
Sin embargo, la hibernación no ocurre en cualquier momento. La documentación especifica qué factores la impiden:
- Las alarmas (alarm), las solicitudes entrantes y los callbacks programados impiden la hibernación.
- Si se está usando
setTimeout/setInterval, el DO no hiberna mientras el timer siga vivo. - Si existe aunque sea un solo WebSocket aceptado con el
ws.accept()estándar (sin soporte de hibernación), esa conexión mantiene al DO despierto todo el tiempo.
En cambio, los frames de ping/pong a nivel de protocolo son una excepción. La documentación indica explícitamente que “los frames de ping entrantes se responden automáticamente con un pong, y este manejo de ping/pong no interfiere con la hibernación”. En otras palabras, el patrón habitual de “enviar un ping cada 30 segundos con setInterval” es justo lo opuesto de lo que necesita la Hibernation API, y en la mayoría de los casos se puede eliminar directamente.
Migración: de addEventListener a webSocketMessage/webSocketClose
Código existente (sin soporte de hibernación)
export class ChatRoom {
constructor(state, env) {
this.state = state;
this.sessions = new Map(); // ws -> { username }
}
async fetch(request) {
const pair = new WebSocketPair();
const [client, server] = Object.values(pair);
server.accept(); // 표준 accept — 하이버네이션 불가
this.sessions.set(server, { username: null });
server.addEventListener("message", (event) => {
this.broadcast(event.data);
});
server.addEventListener("close", () => {
this.sessions.delete(server);
});
// 30초마다 ping — setInterval이 DO를 계속 깨워 둔다
const heartbeat = setInterval(() => server.send("ping"), 30000);
server.addEventListener("close", () => clearInterval(heartbeat));
return new Response(null, { status: 101, webSocket: client });
}
broadcast(message) {
for (const ws of this.sessions.keys()) ws.send(message);
}
}
Este código es común y natural, pero tanto el Map en memoria this.sessions como el timer setInterval hacen que el DO quede en un estado que nunca puede hibernar.
Código con la Hibernation API aplicada
export class ChatRoom {
constructor(ctx, env) {
this.ctx = ctx;
this.env = env;
// 생성자는 하이버네이션 후 깨어날 때마다 다시 호출된다.
// 인메모리 Map을 여기서 채우지 않고, getWebSockets()로 복원한다.
}
async fetch(request) {
const pair = new WebSocketPair();
const [client, server] = Object.values(pair);
// ws.accept() 대신 acceptWebSocket() — 하이버네이션 가능
this.ctx.acceptWebSocket(server);
// 연결별 메타데이터는 attachment로 저장 (최대 16,384바이트)
server.serializeAttachment({ username: null, joinedAt: Date.now() });
return new Response(null, { status: 101, webSocket: client });
}
// addEventListener 대신 DO 클래스 메서드로 정의
async webSocketMessage(ws, message) {
const data = JSON.parse(message);
const meta = ws.deserializeAttachment() ?? {};
if (data.type === "join") {
meta.username = data.username;
ws.serializeAttachment(meta);
}
this.broadcast(JSON.stringify({ from: meta.username, text: data.text }));
}
async webSocketClose(ws, code, reason, wasClean) {
ws.close(code, reason);
}
async webSocketError(ws, error) {
// 비정상 종료 로깅 등
console.error("WebSocket error:", error);
}
broadcast(message) {
for (const ws of this.ctx.getWebSockets()) ws.send(message);
}
}
Resumiendo los cambios:
server.accept()→this.ctx.acceptWebSocket(server)addEventListener("message", ...)→ método de claseasync webSocketMessage(ws, message)addEventListener("close", ...)→async webSocketClose(ws, code, reason, wasClean)- el manejo de errores se separa en
async webSocketError(ws, error) - el
Mapen memoria que guardaba las sesiones se reemplaza porthis.ctx.getWebSockets()(consulta de todos los sockets conectados) y porws.serializeAttachment()/ws.deserializeAttachment()(metadatos por conexión, máximo 16.384 bytes, serializados con structured clone) - el heartbeat con
setIntervalse elimina por completo — el ping/pong lo maneja automáticamente el runtime a nivel de protocolo
En wrangler.toml, hoy en día lo recomendable es usar un DO con backend SQLite.
[[durable_objects.bindings]]
name = "CHAT_ROOM"
class_name = "ChatRoom"
[[migrations]]
tag = "v1"
new_sqlite_classes = ["ChatRoom"]
Patrón de sala de chat/juego: coordinar múltiples clientes con un solo DO
Ya sea una sala de chat o una sala de juego multijugador (por turnos o en tiempo real), el patrón es el mismo: se mapea una sala = una instancia de DO, y ese mismo DO retiene los WebSocket de todos los clientes conectados a esa sala. Con esta estructura, ctx.getWebSockets() devuelve directamente “todos los clientes de esta sala”, así que se puede hacer broadcast sin ninguna lógica adicional de etiquetado o filtrado.
Para una sala de juego, basta con extender el patrón: reflejar en el estado del juego la entrada recibida en webSocketMessage, y en cada tick (o en cada input) recorrer getWebSockets() para repartir el snapshot. Eso sí, se sabe que el límite blando de throughput por DO ronda los 1.000 req/s, y el límite de tamaño de mensaje entrante es de 32MiB. Si se intenta manejar salas con muchísimos usuarios (cientos o miles de conectados simultáneos) en un solo DO, es probable que se choque primero con este límite, así que conviene shardear las salas grandes en varios DOs o agrupar el fan-out del broadcast en lotes.
El estado por sesión (apodo, equipo, posición del personaje, etc.) se mantiene a través de la hibernación si se adjunta al objeto WebSocket con serializeAttachment. Eso sí, el attachment tiene un límite de 16.384 bytes, así que los datos que crecen sin control, como el estado completo de la sala o el historial de chat, no deben ir ahí, sino en el almacenamiento persistente del DO (this.ctx.storage).
Estrategias para restaurar el estado al despertar de la hibernación
Qué sobrevive y qué desaparece
La hibernación borra la instancia del DO (el objeto de JavaScript y las variables que contiene), pero no borra ni las conexiones ni los datos persistentes. En resumen:
- Lo que sobrevive: la propia conexión WebSocket (el cliente no percibe ningún corte), los metadatos por conexión guardados con
serializeAttachment(máximo 16KB), los datos persistentes escritos enthis.ctx.storage. - Lo que desaparece: los
Map/Set/variables normales en memoria creados en el constructor, los timerssetTimeout/setIntervalen curso, el estado capturado en closures.
Por eso, el núcleo de la migración consiste en rediseñar la gestión de estado para que no pase nada aunque “el constructor se vuelva a ejecutar en cualquier momento”. En la práctica, esto suele dividirse en:
- Metadatos breves que solo hacen falta por conexión (apodo, color, hora del último heartbeat, etc.) →
serializeAttachment - Estado compartido por toda la sala pero recalculable (número actual de conectados, lista de usuarios en línea) → en el constructor, recorrer
this.ctx.getWebSockets()y leer el attachment de cada socket para reconstruirlo al vuelo - Datos que deben perdurar de forma permanente (historial de chat, puntuaciones del juego, configuración de la sala) → guardarlos con
this.ctx.storage.get/puten el backend SQLite
export class ChatRoom {
constructor(ctx, env) {
this.ctx = ctx;
// 인메모리 캐시는 "복원 가능한 뷰"로만 취급한다.
// 실제 참조는 필요할 때마다 getWebSockets()로 다시 얻는다.
}
onlineUsernames() {
return this.ctx.getWebSockets()
.map((ws) => ws.deserializeAttachment()?.username)
.filter(Boolean);
}
}
Deja el heartbeat en manos del protocolo, no de setInterval
Como se mencionó antes, el ping/pong de protocolo lo maneja automáticamente el runtime y no interfiere con la hibernación. Si de verdad se necesita un heartbeat personalizado a nivel de aplicación para comprobar si un usuario “sigue vivo”, se recomienda no implementarlo directamente con setInterval, sino recurrir a la API de alarm del DO para despertar periódicamente y revisar el estado. Las alarmas también son un factor que impide la hibernación, pero, a diferencia de setInterval, que retiene al DO de forma permanente, una alarma solo lo despierta brevemente en el momento programado y luego puede volver a dormir.
El límite de tiempo de CPU también sigue aplicando
El handler webSocketMessage sigue siendo, al final, código que corre sobre el runtime de Workers, así que el límite de tiempo de CPU (30 segundos por defecto, ajustable hasta 5 minutos con limits.cpu_ms en wrangler.toml) se aplica igual. Hay que tener cuidado si se ejecutan cálculos síncronos pesados (ordenamientos masivos, compresión, etc.) dentro del handler de mensajes, porque se puede chocar con este límite.
Comparación de costos: DO siempre activo vs. DO con Hibernation aplicada
Estructura de precios (plan Workers Paid, según la documentación oficial de julio de 2026)
| Concepto | Incluido gratis | Tarifa por exceso |
|---|---|---|
| Solicitudes (HTTP, RPC, mensajes WebSocket, alarmas) | 1 millón/mes | $0,15 por millón |
| Duration (GB-s, wall-clock, base fija de 128MB) | 400.000 GB-s/mes | $12,50 por millón de GB-s |
| Almacenamiento SQLite | 5 GB-mes | $0,20 por GB-mes |
| Lecturas de filas SQLite | 25.000 millones de filas/mes | $0,001 por millón de filas |
| Escrituras de filas SQLite | 50 millones de filas/mes | $1,00 por millón de filas |
El plan Workers Paid en sí tiene una tarifa base de $5 al mes que ya incluye 10 millones de solicitudes y 30 millones de ms de CPU, y el cobro de DO se suma aparte, por encima de eso. (Como referencia, un DO con backend SQLite también se puede usar en el plan Workers Free, con límites diarios —100.000 solicitudes/día, 13.000 GB-s de Duration/día, 5 millones de lecturas de filas/día, 100.000 escrituras de filas/día, 5GB de almacenamiento—, así que el plan gratuito alcanza de sobra para prototipar.)
Modelo de cálculo y supuestos
Las cifras que siguen no son una factura real capturada, sino una simulación que aplica directamente la fórmula oficial de precios citada arriba. Se usó la misma fórmula que Cloudflare presenta como ejemplo en su documentación de precios (segundos activos × 128MB/1GB = GB-s).
- 2.000 DOs de sala de chat/juego funcionando durante 30 días (2.592.000 segundos) sin parar
- Siempre activo (sin hibernación): al menos una persona conectada por sala, así que el DO reside en memoria todo el mes
- Con hibernación aplicada: se asume un promedio de 1 mensaje por segundo por sala (1msg/seg), y que procesar ese mensaje (incluido el broadcast) tarda en promedio 10ms — en este caso el DO está “despierto” solo alrededor del 1 % del tiempo total
Resultado
| Caso | Costo de Duration/mes | Costo de solicitudes/mes (igual en ambos) | Total/mes |
|---|---|---|---|
| DO siempre activo | $8.289,40 | $777,45 | $9.066,85 |
| DO con Hibernation aplicada | $77,94 | $777,45 | $855,39 |
Reducción total de costos de aproximadamente el 90,6 % — esto significa que el “90 %” del titular no es una exageración sacada de un escenario específico y favorable, sino una cifra que efectivamente se alcanza incluso con un nivel de actividad de lo más común, de alrededor de 1 mensaje por segundo.
El ahorro varía según el patrón de tráfico
Con los mismos 2.000 DOs y la misma fórmula de cálculo, solo cambiar la frecuencia de mensajes hace que el ahorro varíe muchísimo.
- Baja frecuencia (sala de chat, juego por turnos, un mensaje cada 5 segundos en promedio, procesamiento de 15ms): costo total $8.444,77 → $175,25, ahorro de aproximadamente el 97,9 %
- Frecuencia media (1 mensaje por segundo, procesamiento de 10ms): costo total $9.066,85 → $855,39, ahorro de aproximadamente el 90,6 %
- Alta frecuencia (tick de juego en tiempo real a 10Hz, procesamiento de 5ms): costo total $16.065,25 → $8.185,57, ahorro de aproximadamente el 49,0 %
El patrón es claro: el ahorro en Duration por sí solo es enorme casi sin importar el tráfico (en los tres escenarios anteriores, la Duration por sí sola baja más de un 99 %), pero a medida que aumenta la frecuencia de mensajes, el cobro por solicitudes (requests) pesa más en el costo total, y el porcentaje de ahorro global termina siendo menor. En un juego en tiempo real de alta frecuencia que transmite decenas de veces por segundo su estado, más allá de adoptar la Hibernation API, optimizaciones como bajar el tick rate o reducir los propios mensajes mediante compresión delta e interest management contribuyen más al ahorro de costos.
Checklist de migración y errores comunes
- ¿Se reemplazaron todas las llamadas a
ws.accept()porthis.ctx.acceptWebSocket(ws)? - ¿Se movieron
addEventListener("message"/"close")a los métodos de clasewebSocketMessage/webSocketClose, y se agregó tambiénwebSocketError? - ¿Se eliminó el
Mapde sesiones en memoria del constructor, y se trasladaron los valores necesarios aserializeAttachment(por conexión, límite de 16.384 bytes) o athis.ctx.storage(persistente, para toda la sala)? - ¿Se eliminó el heartbeat
setInterval/setTimeouta nivel de aplicación, o se reemplazó por la API de alarm? — dado que el runtime maneja automáticamente el ping/pong de protocolo, conviene reconsiderar primero si de verdad hace falta un heartbeat personalizado - ¿Se está metiendo una sala con muchísimos conectados simultáneos (de cientos a miles) en un solo DO? — verificar el límite blando de 1.000 req/s y el límite de 32MiB por mensaje
- ¿Se agregó en
wrangler.tomluna migraciónnew_sqlite_classespara pasar al backend SQLite (la combinación que Cloudflare recomienda usar junto con la Hibernation API)? - ¿Queda algún cálculo síncrono pesado dentro de
webSocketMessageque pueda chocar con el límite de CPU (30 segundos por defecto)?
Conclusión
El código WebSocket estándar basado en addEventListener no es mal código. Simplemente encaja mal con el modelo de cobro de Durable Objects. Un DO cuesta dinero mientras está encendido, y la API de WebSocket estándar lo mantiene encendido mientras haya una conexión viva. La Hibernation API separa ambas cosas: deja la conexión en el edge y solo pone a dormir el cómputo, eliminando así el desperdicio estructural.
Vista desde la superficie de la API, la migración en sí no es enorme: accept() → acceptWebSocket(), listeners de eventos → métodos de clase, estado en memoria → attachment/storage. Pero la mayor parte del trabajo real consiste en hacer que la premisa de “el constructor puede volver a invocarse en cualquier momento” impregne toda la base de código, y en eliminar timers ocultos —como un heartbeat personalizado— que impiden la hibernación. Si se revisa la checklist anterior punto por punto, se puede lograr sin demasiado esfuerzo un ahorro de costos cercano al 90 %, sobre todo en workloads con largos períodos de inactividad como el chat o los juegos por turnos, aunque la cifra exacta dependerá del patrón de tráfico.