effidevFlutter · Edge de Cloudflare · Optimización de costes en la nube

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

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 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:

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:

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:

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:

  1. Metadatos breves que solo hacen falta por conexión (apodo, color, hora del último heartbeat, etc.) → serializeAttachment
  2. 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
  3. Datos que deben perdurar de forma permanente (historial de chat, puntuaciones del juego, configuración de la sala) → guardarlos con this.ctx.storage.get/put en 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).

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.

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

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.