Cloudflare Durable Objects: el bug silencioso que borra tus sesiones
Desarrollo

Cloudflare Durable Objects: el bug silencioso que borra tus sesiones

09 May, 2026 • 10 min de lectura

Ilustración de Cloudflare Durable Objects y almacenamiento persistente

Construyes un sistema en tiempo real sobre Cloudflare Workers y Durable Objects. Todo parece funcionar: los eventos llegan, el contador de conexiones activas sube, los logs del Worker devuelven Ok en cada request… y sin embargo, al cabo de unos minutos, la base de datos histórica sigue completamente vacía. Sin un solo error en consola.

Este es el relato de ese bug, cómo lo encontramos y por qué la documentación oficial de Cloudflare tenía la respuesta todo el tiempo.

El síntoma

Un cliente envía un heartbeat periódico a un Cloudflare Worker. El Worker lo delega a un Durable Object (DO) que mantiene el estado en memoria. Cuando una conexión lleva más de 2 minutos sin actividad, el alarm del DO la procesa y la guarda en D1.

El sistema mostraba correctamente la conexión activa en tiempo real. Pero al cerrarla de forma abrupta y esperar varios minutos, la sección de datos históricos seguía vacía.

La investigación

Lo primero fue arrancar wrangler tail para ver los logs en tiempo real:

POST /event/client-A              - Ok
POST https://do/heartbeat         - Ok
Alarm @ 15:54:22                  - Ok
Alarm @ 15:54:50                  - Ok
POST /event/client-A              - Ok
POST https://do/heartbeat         - Ok
Alarm @ 15:55:22                  - Ok
...

Ningún error. Los alarms disparaban puntualmente cada 30 segundos. Al cerrar la pestaña, los pings se detenían y los alarms continuaban un rato más… hasta que desaparecían del log.

La siguiente comprobación fue consultar la base de datos directamente:

npx wrangler d1 execute my-db --remote \
  --command "SELECT client_id, COUNT(*) FROM processed_events GROUP BY client_id"

Resultado:

client-B | 36
client-C | 21
client-A |  0   ← aquí está el problema

Otros clientes tenían eventos registrados, client-A tenía cero. ¿Por qué unos sí y otros no?

La pista: el evento de desconexión explícita

Comparando los logs, la diferencia era clara. Los clientes con datos en la base de datos habían enviado un evento explícito de cierre:

POST /event/client-B (type=disconnect)  - Ok  ✓ (desconexión controlada)
POST /event/client-A (type=heartbeat)   - Ok  ✗ (desconexión abrupta)

El evento de disconnect se envía cuando el cliente cierra la conexión limpiamente. Pero si la conexión se corta abruptamente, este evento puede no llegar al servidor.

En ese caso, la sesión depende del mecanismo de expiración por timeout: el DO espera 2 minutos sin ping y entonces vuelca la sesión a D1. Aquí es donde entraba el bug.

La causa raíz: evicción de Durable Objects

La documentación oficial de Cloudflare dice lo siguiente:

“When a Durable Object becomes idle, it may be evicted from memory. If it has a pending alarm, it will be re-instantiated to execute the alarm.”

La palabra clave es re-instantiated. Cuando Cloudflare desaloja un DO de memoria (aunque tenga un alarm pendiente), al disparar el alarm crea una instancia nueva del DO. Eso significa que el constructor se ejecuta de cero.

En nuestro código, las sesiones vivían exclusivamente en un Map en memoria:

export class SessionDO {
  constructor(state, env) {
    this.sessions = new Map(); // ← vacío en cada nueva instancia
    // ...
  }
}

El alarm se dispara sobre una instancia recién creada con un Map vacío. No hay sesiones que flushear. El alarm devuelve Ok (porque no lanzó ninguna excepción) y no reprograma el siguiente alarm (porque sessions.size === 0). La sesión desaparece para siempre.

El flujo del bug

  Conexión activa             Alarm cada 30s
  ───────────────────────────────────────────────
  heartbeat 1 →  DO crea estado en Map
  heartbeat 2 →  DO actualiza Map
  ...
  heartbeat 9 →  DO actualiza Map
  [corte abrupto]
               Alarm t+30s  →  DO en memoria, no expirado (< 2 min), OK
               Alarm t+60s  →  DO en memoria, no expirado, OK
               Alarm t+90s  →  [Cloudflare evicta el DO de memoria]
               Alarm t+120s →  DO re-instanciado, Map vacío → nada que procesar ✗
               [el estado se pierde]
  Conexión activa             Alarm cada 30s
  ────────────────────────────────────────────────
  heartbeat 1 →  DO crea estado en Map
  [cliente envía type=disconnect]
               Alarm t+30s  →  marcado para cierre → FLUSH a D1 ✓
               [el estado se guarda correctamente]

La diferencia: las sesiones que terminan con end=1 se flushean en el siguiente alarm, mucho antes de que el DO tenga tiempo de ser eviccionado. Las sesiones que terminan por timeout dependen de que el DO sobreviva en memoria durante 2+ minutos sin recibir requests, lo cual Cloudflare no garantiza.

La solución: persistir sesiones en DO storage

El DO storage (equivalente a un KV local por instancia) sí sobrevive a las evicciones: cuando el DO es re-instanciado, puede leer del storage lo que persistió antes de ser desalojado. La solución es escribir cada estado en storage y restaurarlo en el constructor.

Constructor: restaurar el estado del storage

constructor(state, env) {
  this.state = state;
  this.env = env;
  this.activeClients = new Map();
  this.state.blockConcurrencyWhile(async () => {
    // Restaurar clientes persistidos antes de una posible evicción
    const stored = await this.state.storage.list({ prefix: 'client:' });
    for (const [key, data] of stored) {
      this.activeClients.set(key.slice(7), data); // key = 'client:{id}'
    }
  });
}

Handler: persistir en storage además de memoria

if (this.activeClients.has(clientId)) {
  const clientData = this.activeClients.get(clientId);
  clientData.lastActive = now;
  if (isDisconnect) clientData.ended = true;
  await this.state.storage.put('client:' + clientId, clientData); // ← persiste
} else {
  const clientData = { startedAt: now, lastActive: now, ended: false };
  this.activeClients.set(clientId, clientData);
  await this.state.storage.put('client:' + clientId, clientData); // ← persiste
}

Alarm: borrar del storage al procesar

async alarm() {
  const toProcess = [...this.activeClients.entries()].filter(
    ([, data]) => data.ended || Date.now() - data.lastActive > EXPIRY
  );
  if (toProcess.length) {
    await this.flushToDB(toProcess);
    const keys = toProcess.map(([id]) => 'client:' + id);
    await this.state.storage.delete(keys); // ← limpia storage
    toProcess.forEach(([id]) => this.activeClients.delete(id));
  }
  if (this.activeClients.size > 0) {
    await this.state.storage.setAlarm(Date.now() + 30_000);
  }
}

El flujo corregido

  Conexión activa             Alarm cada 30s
  ────────────────────────────────────────────────────────────
  heartbeat 1 →  DO crea estado en Map + storage['client:id']
  heartbeat 2 →  DO actualiza Map + storage['client:id']
  ...
  heartbeat 9 →  DO actualiza Map + storage['client:id']
  [corte abrupto]
               Alarm t+30s  →  no expirado (< 2 min), OK
               Alarm t+90s  →  [Cloudflare evicta el DO]
               Alarm t+120s →  DO re-instanciado, constructor lee storage
                               → Map restaurado con el cliente ✓
                               → expirado (> 2 min) → FLUSH a D1 ✓
                               → storage['client:id'] borrado
               [el estado se guarda correctamente]

Coste del cambio

Una pregunta legítima: ¿no contradice esto la optimización de minimizar escrituras?

No, porque hablamos de tipos de storage distintos:

Storage Coste Uso
D1 Por fila escrita Solo al cerrar sesión (1 vez)
DO storage Por operación KV En cada ping (~1/min por sesión activa)

El DO storage es varios órdenes de magnitud más barato que D1 y está diseñado precisamente para este tipo de estado efímero. Para 1.000 sesiones activas simultáneas son ~1.000 writes/minuto de KV, un coste negligible en el plan de Cloudflare Workers.

Lo que esto enseña sobre Durable Objects

Los Durable Objects son una herramienta poderosa, pero con un comportamiento que sorprende la primera vez:

  1. La memoria es volátil. Cualquier estado que no esté en this.state.storage puede perderse en cualquier momento.
  2. “Pending alarm” no significa “DO vivo”. Cloudflare garantiza que el alarm disparará, no que el DO seguirá en memoria hasta entonces.
  3. Ok en el alarm no significa éxito real. Un alarm que procesa un Map vacío devuelve Ok sin escribir nada — no hay forma de distinguirlo de un alarm que sí flusheó datos, a menos que lo instrumentes explícitamente.
  4. El patrón correcto: memoria para velocidad, storage para durabilidad. Usar ambas en paralelo es el diseño que Cloudflare espera que uses.

La referencia oficial: Cloudflare Durable Objects — Alarms.