Durable Objects: cuando el dato tiene que ponerse de acuerdo
Hay una propiedad que ni KV ni D1 ni R2 pueden ofrecer y que ninguna caché ni ningún índice añaden después: que todas las operaciones sobre una misma entidad ocurran en un orden único y con el cómputo pegado al estado. Esa es la razón de existir de los Durable Objects. Qué garantizan exactamente —unicidad global de la instancia y ejecución por turnos—, las tres familias de casos que solo ellos resuelven bien (tiempo real, contadores exactos, locks y cuotas), cómo elegir el grano de la identidad para no fabricar un cuello de botella, y las tres cosas que un Durable Object no es.
Las tres primeras piezas del catálogo responden a preguntas sobre dónde vive un dato. Los Durable Objects responden a otra distinta y bastante más incómoda: qué pasa cuando varias peticiones llegan a la vez y el resultado depende del orden. La concurrencia es la única propiedad de un sistema que no se puede añadir a posteriori. Una caché se pone después, un índice se crea después, una réplica se activa después; pero un dato que necesitaba orden y no lo tuvo no falla al escribirse, falla más tarde, de forma intermitente y en producción. Un Durable Object es la pieza que pone ese orden, y saber reconocer cuándo hace falta es la parte difícil de este nivel.
- Nombrar con precisión las dos garantías que solo un
Durable Objectofrece. - Identificar las tres familias de casos que exigen coordinación por entidad.
- Elegir el grano de la identidad para que la coordinación no se vuelva un cuello de botella.
- Reconocer las tres cosas que un
Durable Objectno es, y cuándo salir de él.
Unicidad y turno
Un Durable Object combina dos garantías que, por separado, no son especialmente notables y que juntas cambian lo que puedes construir. La primera es la unicidad: para un identificador dado existe, en todo el planeta, exactamente una instancia viva. No hay dos copias que puedan divergir, ni un consenso que negociar entre réplicas, porque no hay réplicas. La segunda es el turno: esa instancia procesa las peticiones de una en una, en un solo hilo, así que dentro de ella no existen las condiciones de carrera.
A esas dos se suma un detalle de colocación que es el que las vuelve prácticas: el estado durable del objeto vive junto al cómputo que lo manipula. Leer, decidir y escribir no son tres viajes de red sino tres operaciones locales dentro del mismo turno. Ahí está la diferencia esencial con cualquier almacén remoto: en KV o en D1, el ciclo de leer, modificar y escribir atraviesa la red, y es justo en ese hueco donde se cuelan las actualizaciones perdidas.
Conviene subrayar que ninguna de las dos garantías sirve por separado. Una instancia única que atendiera peticiones en paralelo seguiría mezclando operaciones dentro de sí misma y volvería a abrir el hueco. Turnos repartidos entre varias instancias no impondrían ningún orden común, porque cada una ordenaría solo lo suyo. Solo la conjunción de las dos —una instancia, un hilo, el estado al lado— convierte el ciclo completo en un acto indivisible, y ese acto indivisible es justamente lo que la red no sabe darte gratis.
export class Contador extends DurableObject {
// este metodo se ejecuta en un turno exclusivo: nadie mas toca el estado
async incrementar(cantidad: number): Promise<number> {
const actual = (await this.ctx.storage.get<number>('total')) ?? 0;
const siguiente = actual + cantidad;
await this.ctx.storage.put('total', siguiente);
return siguiente;
}
}
// desde el Worker: la identidad se deriva del nombre natural de la entidad
const id = env.CONTADOR.idFromName(`producto:${productoId}`);
const stub = env.CONTADOR.get(id);
const total = await stub.incrementar(1);
flowchart LR P1[peticion A] --> DO[Durable Object unico por entidad] P2[peticion B] --> DO P3[peticion C] --> DO DO --> T[turnos en serie sin carreras] T --> S[storage local y transaccional] style DO fill:#cba6f7,color:#11111b style T fill:#a6e3a1,color:#11111b
Antes de elegir, imagina a dos usuarios ejecutando la misma operación en el mismo milisegundo sobre el mismo dato. Si el resultado correcto depende de cuál va primero, o si uno de los dos debe perder explícitamente —el último asiento, la última unidad, el permiso de escritura—, necesitas coordinación. Si el resultado es idéntico caiga como caiga, no la necesitas y estarás pagando un cuello de botella a cambio de nada.
Tres familias de casos
Los casos que exigen un árbitro no son infinitos ni caprichosos: caen casi siempre en una de tres familias, y reconocer la familia es más rápido que razonar el caso desde cero.
La primera familia es el tiempo real: salas de chat, edición colaborativa, partidas, paneles en vivo, subastas. Todas comparten la misma forma: un conjunto de participantes conectados a la vez a una entidad compartida, donde cada mensaje debe llegar a todos en el mismo orden. Un Durable Object es el punto de encuentro natural porque sostiene las conexiones abiertas, guarda el estado de la sala y ordena los eventos sin coordinación externa.
Lo interesante del caso en tiempo real es que el objeto no solo guarda estado: sostiene conexiones. Las conexiones abiertas de una sala viven en la misma instancia que decide el orden de los mensajes, así que difundir a todos los participantes no requiere ningún bus, ninguna suscripción externa ni ningún mecanismo de reparto entre nodos. Esa colocación es la que hace que un chat o un documento colaborativo quepan en unas pocas decenas de líneas, cuando en una arquitectura regional habrían pedido una pieza entera de mensajería.
La segunda es el conteo exacto: existencias, cuotas, saldos, asientos, votos, límites de tasa. Aquí la palabra clave es exacto. Muchos contadores no lo necesitan —el número de visitas de un artículo tolera perfectamente ser aproximado y agregado en lotes— y para esos un Durable Object es exceso de precisión y de coste. Pero cuando vender una unidad de más significa un cliente enfadado o un descuadre contable, la exactitud deja de ser un lujo.
La tercera es la exclusión mutua: locks, arrendamientos, elección de líder, garantizar que un proceso caro se ejecuta una sola vez aunque diez peticiones lo pidan a la vez. El objeto se convierte en el árbitro que concede o deniega, y como solo hay uno y atiende por turnos, su decisión es incontestable por construcción.
El límite de tasa es el ejemplo más didáctico de esta tercera familia, porque condensa todo el argumento en pocas líneas: hay una decisión que depende del estado, el estado cambia con cada decisión, y varias peticiones concurrentes compiten por él.
export class Cuota extends DurableObject {
async consumir(limite: number, ventanaMs: number): Promise<boolean> {
const ahora = Date.now();
const s = (await this.ctx.storage.get<{ inicio: number; usos: number }>('v'))
?? { inicio: ahora, usos: 0 };
if (ahora - s.inicio >= ventanaMs) { s.inicio = ahora; s.usos = 0; }
if (s.usos >= limite) return false;
s.usos += 1;
await this.ctx.storage.put('v', s);
return true; // decision tomada dentro de un turno exclusivo
}
}
Ese mismo algoritmo escrito contra KV es incorrecto y lo es de forma silenciosa: diez peticiones simultáneas leen el mismo contador, todas concluyen que queda cuota y todas pasan. No lanza ningún error, no aparece en los registros y solo se manifiesta como un límite que, misteriosamente, no limita bajo carga alta, que es exactamente cuando importaba.
Tiempo real
Salas, colaboración, partidas, subastas: muchos participantes sobre una entidad compartida que necesita un orden común de eventos.
Conteo exacto
Existencias, asientos, cuotas y saldos donde vender una unidad de más tiene consecuencias reales.
Exclusión mutua
Locks, arrendamientos y ejecuciones únicas: alguien tiene que decir quién pasa, y esa decisión no admite dos opiniones.
El grano de la identidad
Decidido que un caso necesita árbitro, queda la decisión que de verdad separa un diseño que escala de uno que se ahoga, y no es la que la gente espera.
La decisión que más veces se hace mal no es si usar Durable Objects, sino cuántos. Como cada objeto procesa en serie, su capacidad es finita por diseño; repartir bien las entidades entre muchos objetos es lo que convierte esa serialización en una virtud en lugar de un embudo. Un objeto por sala, por producto, por usuario, por inquilino, por clave de API: el grano correcto es la unidad más pequeña sobre la que existe competencia real.
Hay un matiz de coste que refuerza la elección del grano fino y que a veces se teme sin motivo: tener muchos objetos no es caro por el hecho de existir. Un Durable Object inactivo no consume; se activa cuando alguien lo invoca y se duerme cuando deja de haber trabajo, conservando su estado durable intacto. Eso significa que un objeto por sala, por producto o por clave de API es perfectamente viable a escala de millones, y que el instinto de agrupar entidades para ahorrar objetos optimiza justo lo que no había que optimizar mientras destruye el paralelismo.
El error clásico es el objeto global: un único Durable Object para el contador de toda la aplicación, o para el lock de todo el sistema. Funciona en desarrollo con un usuario y se derrumba en cuanto el tráfico crece, porque has reducido tu sistema distribuido a un solo hilo. El error simétrico, menos frecuente pero igual de dañino, es el grano demasiado fino: un objeto por evento o por petición, que no coordina nada porque nadie compite por él y solo añade latencia.
Un criterio operativo ayuda a acertar el grano: mira quién compite. Si dos peticiones que tocan entidades distintas acabarían en el mismo objeto, el grano es demasiado grueso y estás serializando a gente que no tenía nada que ver. Si dos peticiones que tocan la misma entidad acaban en objetos distintos, el grano es demasiado fino y la coordinación no existe. El grano correcto es aquel en que la frontera del objeto coincide exactamente con la frontera de la competencia.
La identidad se deriva del dominio, no de la infraestructura. Cuando el objeto representa una entidad que ya tiene nombre natural —la sala general, el producto con un identificador concreto, el inquilino de un dominio—, idFromName produce un identificador determinista y cualquier Worker del planeta llega a la misma instancia sin consultar un registro. Cuando la entidad nace en ese instante y nadie la va a buscar por nombre, newUniqueId es la opción correcta y más eficiente.
La unicidad tiene un precio geográfico: la instancia se sitúa cerca de quien la creó y ahí se queda, así que un usuario en el otro extremo del mundo paga el viaje completo. Para coordinación es un coste justo, porque el orden global no se puede comprar más barato. Pero convierte a los Durable Objects en un pésimo destino para lecturas masivas que no necesitan coordinarse: esas se sirven desde KV, desde la caché del edge o desde una réplica de D1, dejando al objeto solo el trabajo que exige un árbitro.
Las tres cosas que no es
Enunciar lo que una herramienta no es resulta tan útil como enunciar lo que hace, porque los errores caros rara vez vienen de usarla mal en su terreno: vienen de estirarla fuera de él, donde funciona lo suficiente como para no dar la alarma y lo bastante mal como para doler más tarde.
Un Durable Object no es una base de datos consultable. No hay forma de preguntar por todos los objetos que cumplan un criterio, porque no existe un índice global sobre ellos: cada uno es una isla direccionable por identidad. Si necesitas listar, buscar o agregar entre entidades, ese índice vive en D1 y el objeto guarda solo el estado que se coordina.
No es un almacén de bytes. Su estado está pensado para valores pequeños y frecuentes, no para adjuntos ni ficheros; los bytes van a R2 y el objeto guarda, como mucho, la referencia.
No es una caché global. Su valor es el orden, no la cercanía; usarlo para servir lecturas que cualquier réplica resolvería es pagar un embudo a cambio de una garantía que ese dato no pedía.
De esas tres negaciones sale el patrón de convivencia habitual, que verás repetirse en cualquier sistema serio. El objeto posee el estado que se coordina y nada más: el aforo restante, el turno actual, la lista de conectados. Cuando ese estado cambia de forma relevante para el resto del sistema, el objeto proyecta el resultado hacia donde se pueda consultar —una fila en D1 para que el panel de administración pueda listar y agregar, una entrada en KV para que la portada muestre plazas disponibles sin molestar al árbitro—. La proyección va siempre en esa dirección, del árbitro hacia los almacenes consultables, nunca al revés: en cuanto alguien escribe el estado coordinado desde fuera del objeto, la garantía que justificaba su existencia deja de valer.
Puede parecer que copiar el aforo a una fila de D1 crea una segunda fuente de verdad, pero no es así siempre que la dirección sea única y el papel esté claro: el objeto decide, la fila recuerda para poder ser consultada. La prueba es sencilla: si borras la fila, se puede reconstruir preguntando al objeto; si borras el estado del objeto, la información se ha perdido. Quien posee el dato es aquel cuya pérdida no se puede reparar.
El catálogo de almacenamiento invita a pensar que los Durable Objects son otra opción más para guardar datos, un poco como KV pero con transacciones. Esa lectura pierde lo esencial. Las demás piezas responden a la pregunta de dónde vive un hecho; los Durable Objects responden a otra bastante más rara, que es quién decide qué pasó primero. Y esa pregunta solo tiene respuesta si existe un lugar único donde el tiempo transcurre linealmente para esa entidad, porque el orden no es una propiedad del dato sino de las operaciones que lo tocan. Ahí está la razón profunda de que las dos garantías vayan juntas y de que ninguna sirva sola: una instancia única sin turnos podría seguir mezclando operaciones concurrentes dentro de sí misma, y turnos repartidos entre varias instancias no impondrían ningún orden común. Solo la conjunción —una instancia, un hilo, el estado al lado del cómputo— convierte el ciclo de leer, decidir y escribir en un acto indivisible, y ese acto indivisible es exactamente lo que la red no sabe darte gratis. Verlo así reordena la elección de arquitectura. Deja de tener sentido preguntar si uso Durable Objects en lugar de D1, porque no ocupan el mismo hueco conceptual: uno decide, el otro recuerda, y una aplicación seria necesita casi siempre las dos cosas. La pregunta útil pasa a ser cuáles de mis hechos exigen un árbitro, y esa lista es sorprendentemente corta en casi cualquier producto —el stock, la cuota, la sala, el lock— mientras que la lista de hechos que solo necesitan ser recordados y consultados es larguísima. Reconocer esa asimetría es lo que separa una arquitectura sana de una que serializa por miedo. El principiante coordina todo, por si acaso, y construye un sistema distribuido con la capacidad de un solo hilo; el que ha entendido el modelo coordina lo mínimo, con el grano más fino que el dominio permita, y deja que el resto del sistema disfrute de la paralelización masiva del edge. La destreza no está en saber escribir un Durable Object, que es fácil, sino en tener el criterio para saber cuán poco de tu sistema debería serlo.
- Aplica la prueba de los dos usuarios a cinco operaciones de una aplicación real y decide en cuáles el orden cambia el resultado.
- Distingue dos contadores de tu producto: uno que tolera ser aproximado y otro que exige exactitud. Justifica la diferencia desde el negocio.
- Elige el grano de identidad para un límite de tasa: por usuario, por clave de API o global. Explica qué se rompe con cada elección equivocada.
- Explica por qué un
Durable Objectes un mal destino para lecturas globales masivas y qué pieza usarías delante. - Diseña la frontera: qué guarda el objeto y qué guarda
D1en un sistema de reservas de asientos que además debe listar reservas por usuario.