wandres.dev
DO: STORAGE Y ALARMS · estado transaccional

El storage transaccional: la memoria privada del objeto

Un Durable Object no es solo cómputo con identidad: trae consigo un almacén persistente y privado, state.storage, que sobrevive a reinicios, desalojos y despliegues. Recorremos su API de clave-valor —get, put, delete, list—, por qué cada objeto ve solo su propia memoria, cómo el esquema de claves modela colecciones enteras, y en qué sentido preciso ese almacén es fuertemente consistente y transaccional.

⏱ 18 min

Un Worker corriente es amnésico: cierra la respuesta y lo olvida todo. Un Durable Object rompe ese olvido por dos vías a la vez. La primera, que ya conoces, es la identidad: existe una sola instancia por nombre en toda la red. La segunda, que abrimos aquí, es la memoria durable: cada objeto trae consigo un almacén privado, state.storage, que vive pegado a él en disco y sobrevive a reinicios, desalojos y despliegues. No es una base de datos remota a la que te conectas con una cadena de credenciales; es la memoria propia del objeto, y solo ese objeto puede mirarla.

🎯 Al terminar esta lección sabrás
  • Distinguir el estado en memoria, volátil, del almacén state.storage, que persiste.
  • Manejar la API de clave-valor del almacén: get, put, delete y list.
  • Modelar colecciones enteras con un esquema de claves y recorrerlas como rangos con list.
  • Precisar en qué sentido el almacén es persistente, fuertemente consistente y transaccional.

Un almacen privado por objeto

Cada Durable Object recibe, al construirse, un objeto de estado —el DurableObjectState, que por convención se llama state— y su propiedad más importante es storage. Ese almacén es un espacio de clave-valor persistente que pertenece a ese objeto y a ningún otro: dos objetos de la misma clase, con nombres distintos, no comparten ni una sola clave. No existe un espacio global; existen tantos almacenes diminutos como objetos vivos, cada uno encerrado dentro de su dueño.

Conviene separar las dos memorias que el objeto tiene a la vez. Los campos de la instancia —lo que guardas en this— son estado en memoria: rapidísimos, pero volátiles; cuando la plataforma recicla el isolate por inactividad, se evaporan sin dejar rastro. Sirven como caché de lo que ya cargaste, nunca como fuente de verdad.

El almacén state.storage es la memoria durable: cada escritura se persiste en disco junto al objeto, de modo que al despertar más tarde el objeto puede releer exactamente lo que dejó escrito. La regla práctica que gobierna todo el nivel es sencilla: lo que no puedas permitirte perder vive en state.storage; lo demás, en this.

export class Contador {
  constructor(private state: DurableObjectState, private env: Env) {}

  async fetch(request: Request): Promise<Response> {
    // lee del almacen durable, no de un campo volatil
    const actual = (await this.state.storage.get<number>("n")) ?? 0;
    const siguiente = actual + 1;
    await this.state.storage.put("n", siguiente);
    return Response.json({ n: siguiente });
  }
}

Si escribes con la clase base moderna que extiende DurableObject, el mismo objeto de estado está en this.ctx: this.ctx.storage y this.state.storage nombran la misma memoria. A lo largo de este nivel usaremos la forma explícita con state para que el almacén quede siempre a la vista.

get, put y delete

La superficie del almacén es una API de clave-valor asíncrona, deliberadamente pequeña. Las claves son cadenas de hasta 2 KiB; los valores, cualquier dato serializable de hasta 128 KiB. Cada método devuelve una promesa, porque leer y escribir tocan el disco del objeto, aunque ese disco esté a su lado y no al otro extremo de una red.

// escribir una clave, o varias de una sola vez
await this.state.storage.put("perfil", { nombre: "Ada", plan: "pro" });
await this.state.storage.put({ visitas: 10, activo: true });

// leer una, o varias; pedir varias devuelve un Map
const perfil = await this.state.storage.get<Perfil>("perfil");
const varias = await this.state.storage.get<number>(["visitas", "intentos"]);

// borrar una clave; devuelve si existia
await this.state.storage.delete("activo");

El detalle que importa es que put acepta tanto una clave suelta como un objeto de pares, y get tanto una clave como un arreglo de claves. Esa forma por lotes no es azúcar sintáctica: agrupa varias operaciones en un solo viaje al almacén y, como veremos, en una sola unidad atómica.

Que las llamadas sean asíncronas no significa que siempre toquen el disco. La plataforma mantiene una caché en memoria de las claves leídas y escritas hace poco, así que un get sobre algo reciente suele resolverse sin ir al disco. La promesa está ahí porque puede haber lectura de disco, no porque siempre la haya; en el camino caliente, el almacén es casi tan rápido como un campo de la instancia, pero sin renunciar a la durabilidad.

list y el esquema de claves

El método list es el que convierte un simple clave-valor en algo con la potencia de una colección. Devuelve un Map ordenado lexicográficamente por clave y acepta prefix, start, end, limit y reverse. Ese orden no es un adorno: es lo que te deja diseñar un esquema de claves y recorrer subconjuntos como rangos, sin cargar todo en memoria.

// modela una coleccion con claves compuestas y ordenables
await this.state.storage.put("sesion:2026-01:abc", { ip: "10.0.0.1" });
await this.state.storage.put("sesion:2026-02:def", { ip: "10.0.0.2" });

// recorre solo un prefijo, ordenado, con tope de resultados
const enero = await this.state.storage.list<Sesion>({
  prefix: "sesion:2026-01:",
  limit: 100,
});

for (const [clave, sesion] of enero) {
  console.log(clave, sesion.ip);
}

Un solo objeto puede contener así miles de entradas y consultarlas por rango con una latencia mínima, porque el índice vive en el mismo disco que el código. Elegir bien las claves —jerárquicas, ordenables, con separadores estables— es todo el diseño de datos que necesitas para colecciones de tamaño moderado dentro de un objeto.

Cuando una colección crece, list no te obliga a traerlo todo: el limit acota cada página y, combinado con start, te deja avanzar en tandas usando la última clave leída como punto de partida de la siguiente. Ese recorrido paginado, junto con reverse para ir de atrás hacia delante, cubre casi todos los patrones de acceso —los últimos N, el rango de un mes, el barrido completo por lotes— sin que el clave-valor tenga que fingir que es un motor de consultas.

Persistente, consistente y transaccional

Tres garantías separan a este almacén de una caché cualquiera, y conviene nombrarlas con precisión antes de confiarles nada serio.

💾

Persistente

Sobrevive al reciclado del isolate, a los reinicios de la plataforma y a los despliegues de código nuevo. Lo que un put confirmó sigue ahí cuando el objeto vuelve a la vida, sin que tú hagas nada para rescatarlo.

🎯

Fuertemente consistente

En cuanto un put se resuelve, cualquier lectura posterior ve ese valor. Lees siempre tus propias escrituras, sin ventanas de propagación ni réplicas que se pongan al día con retraso.

🔒

Transaccional

Las escrituras de una misma invocación se agrupan y se confirman de forma atómica: un observador nunca ve la mitad de un cambio. O se aplican todas, o no se aplica ninguna.

flowchart LR
REQ[peticion al objeto] --> DO[Durable Object]
DO --> MEM[campos en memoria]
DO --> ST[state.storage en disco]
MEM -.se pierde al reciclar.-> VOID[vacio]
ST -.sobrevive y se relee.-> DO
style ST fill:#a6e3a1,color:#11111b
style VOID fill:#f38ba8,color:#11111b

Esa atomicidad casi nunca la pides a mano, porque la plataforma la regala con dos mecanismos silenciosos: agrupa las escrituras pendientes de una invocación y retiene la respuesta de red hasta que están durablemente confirmadas. Cuando de verdad necesitas encerrar varias operaciones bajo una condición y revertirlas juntas, existe state.storage.transaction, que ejecuta un bloque y lo deshace entero si algo lanza.

// varias escrituras bajo una condicion: o se aplican todas, o ninguna
await this.state.storage.transaction(async (txn) => {
  const saldo = (await txn.get<number>("saldo")) ?? 0;
  if (saldo < 100) throw new Error("saldo insuficiente"); // revierte el bloque
  await txn.put("saldo", saldo - 100);
  await txn.put("ultimo-cargo", Date.now());
});

La excepción dentro del bloque no deja el almacén a medias: la resta del saldo y el registro del cargo se confirman juntos o no se confirma ninguno. Fíjate en que este throw no es manejo de errores, es control de flujo transaccional: lanzar es la forma de decir “aborta y revierte”.

Vale la pena insistir en lo raro que es tener esto casi gratis. En un almacén remoto, envolver dos escrituras en una transacción implica abrir una, mantener el candado mientras dura el viaje de red, y arriesgarse a bloqueos y reintentos si otro cliente compite. Aquí la transacción es local y el único cliente eres tú, así que el coste se reduce a lo que de verdad cuesta: persistir los cambios en un disco que tienes al lado.

📝
El almacen no es una base de datos remota

Nada de esto se parece a abrir una conexion, pasar una cadena con usuario y contrasena y viajar por la red hasta un servidor de base de datos. El almacen vive en la misma maquina que el objeto, se accede por llamada directa y solo responde a su dueno. No hay pool de conexiones que dimensionar, no hay latencia de red entre el codigo y sus datos, no hay credenciales que rotar: hay un objeto y su memoria, coubicados.

El objeto que recuerda disuelve la frontera entre computo y estado

Durante medio siglo, la arquitectura de backend se levantó sobre una separación que parecía ley natural: de un lado el cómputo, efímero y sin memoria; del otro el estado, encerrado en una base de datos a la que el cómputo se conecta por la red. Casi toda la disciplina que aprendimos —pools de conexiones, cachés, niveles de aislamiento, credenciales, la latencia perpetua entre la aplicación y su almacén— nace de que esos dos mundos viven separados y tienen que hablar a través de un cable. El Durable Object colapsa esa frontera. El estado deja de estar al otro lado de la red: está pegado al cómputo, en la misma máquina, dentro del mismo dueño, y solo ese dueño lo ve. La consecuencia es honda. La consistencia deja de ser un protocolo que negocias —lecturas repetibles, propagación eventual, resolución de conflictos entre réplicas— y pasa a ser una propiedad de la topología: como hay una sola instancia del objeto y su memoria está a su lado, leer tus propias escrituras es tan trivial como leer una variable que acabas de asignar. La palabra que hay que interiorizar es coubicación. Cuando el cómputo y su estado comparten lugar e identidad, se desvanece de golpe una familia entera de problemas que dábamos por eternos, y el ingeniero deja de preguntarse cómo mantener sincronizadas dos copias de la verdad para descubrir que, por diseño, nunca hubo más que una.

⚔️ Dale memoria a un objeto
  1. Escribe un Durable Object Contador que lea n de state.storage, lo incremente y lo vuelva a guardar en cada fetch. Llámalo varias veces y observa que el valor crece.
  2. Fuerza un redespliegue del Worker y verifica que el contador sigue donde estaba: demuéstrate a ti mismo que el estado sobrevivió al despliegue.
  3. Guarda diez claves con el prefijo sesion: y recupéralas con list({ prefix: "sesion:" }). Comprueba que llegan ordenadas por clave y no por orden de inserción.
  4. Explica la diferencia entre guardar el contador en this.n y guardarlo en state.storage, y qué le ocurre a cada uno cuando el isolate se recicla.
  5. Argumenta por qué “leer tus propias escrituras” es trivial en un Durable Object y caro de garantizar en un sistema con muchas réplicas.