Durable Objects: Stato Fortemente Consistente e WebSocket sull'Edge
I Durable Objects sono la primitiva più potente dell'ecosistema Cloudflare: permettono stato fortemente consistente, WebSocket con session management e coordinazione distribuita senza server centralizzati, tutto all'edge globale.
Il Problema dello Stato nell'Edge Computing
Gli articoli precedenti di questa serie hanno mostrato come Cloudflare Workers eccella per workload stateless: ogni richiesta viene gestita in isolamento, senza memoria condivisa tra invocazioni. Questa caratteristica è un punto di forza per la scalabilità orizzontale, ma diventa un ostacolo non appena l'applicazione richiede coordinazione.
Considera questi scenari comuni: una stanza di chat dove i messaggi devono essere ordinati e consegnati a tutti i partecipanti; un documento collaborativo dove più utenti editano contemporaneamente; un rate limiter per API che deve contare richieste in modo globalmente coerente. In tutti questi casi il modello stateless dei Workers non basta: serve uno stato fortemente consistente e la capacità di coordinare richieste concorrenti in un unico punto.
È esattamente il problema che i Durable Objects risolvono.
Cosa Imparerai
- Cos'è un Durable Object e come differisce da Workers KV
- Il modello di consistenza forte: single-writer, global coordination
- Come implementare WebSocket con session management usando Durable Objects
- Storage transazionale: read, write e transazioni atomiche
- Pattern per chat room, rate limiting e document collaboration
- Alarms: operazioni schedulate all'interno del Durable Object
- Limiti, pricing e quando scegliere DO vs KV vs D1
Cos'è un Durable Object
Un Durable Object (DO) è una classe JavaScript/TypeScript che Cloudflare istanzia in un'unica location geografica per un dato identificatore. A differenza di Workers KV (eventual consistency su 300+ PoP), un DO ha queste garanzie:
- Single-writer consistency: una sola istanza del DO esiste in tutto il mondo per un dato ID
- Serializzazione delle richieste: le chiamate al DO vengono eseguite sequenzialmente, mai in concorrenza
- Storage durabile: un key-value store transazionale privato per ogni istanza
- WebSocket hibernation: le connessioni WebSocket sopravvivono agli idle periods senza costo
La location di un DO viene determinata automaticamente al momento della prima attivazione e rimane fissa. Cloudflare sceglie il datacenter più vicino alla prima richiesta. Tutte le richieste successive, ovunque nel mondo, vengono instradate a quella singola istanza tramite il routing anycast di Cloudflare.
| Primitiva | Consistenza | Concorrenza | Latenza lettura | Caso d'uso |
|---|---|---|---|---|
| Workers KV | Eventual (minuti) | Multi-writer | ~1ms (cached) | Config, asset, sessioni read-heavy |
| Durable Objects | Forte (linearizable) | Single-writer | ~50-150ms (da edge remoto) | Chat, rate limiter, game state, CRDT |
| D1 SQLite | Forte (primary) | Multi-reader, single-writer | ~5-20ms (da PoP vicino) | Query relazionali, report, OLTP |
| R2 Object Storage | Forte (per oggetto) | Multi-writer (conflict detection) | ~50-200ms | File, immagini, backup |
Struttura di un Durable Object
Un Durable Object è una classe con metodi specifici che il runtime Cloudflare
riconosce. Il più importante è fetch(), chiamato per ogni
richiesta HTTP inoltrata al DO. Vediamo la struttura di base:
// src/counter-do.ts
// Un Durable Object semplice: un contatore con persistenza
export class CounterDO implements DurableObject {
private state: DurableObjectState;
private env: Env;
constructor(state: DurableObjectState, env: Env) {
this.state = state;
this.env = env;
// state.storage e il key-value store privato di questa istanza
// Persiste attraverso i riavvii del DO
}
async fetch(request: Request): Promise<Response> {
const url = new URL(request.url);
switch (url.pathname) {
case '/increment': {
// Legge il valore corrente (undefined se non esiste)
const current = (await this.state.storage.get<number>('count')) ?? 0;
const next = current + 1;
// Scrittura atomica: garantita durabile prima del return
await this.state.storage.put('count', next);
return Response.json({ count: next });
}
case '/decrement': {
const current = (await this.state.storage.get<number>('count')) ?? 0;
const next = Math.max(0, current - 1);
await this.state.storage.put('count', next);
return Response.json({ count: next });
}
case '/value': {
const count = (await this.state.storage.get<number>('count')) ?? 0;
return Response.json({ count });
}
case '/reset': {
await this.state.storage.put('count', 0);
return Response.json({ count: 0, reset: true });
}
default:
return new Response('Not Found', { status: 404 });
}
}
}
interface Env {
COUNTER: DurableObjectNamespace;
}
Per usare il DO dal Worker entry point, devi istanziarlo tramite il namespace binding e un ID:
// src/worker.ts - entry point del Worker
export { CounterDO } from './counter-do';
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
// Estrae l'ID dalla query string: /counter?id=room-1
const counterId = url.searchParams.get('id') ?? 'global';
// idFromName() crea un ID deterministico da una stringa
// Lo stesso nome produce sempre lo stesso ID (e la stessa istanza)
const id = env.COUNTER.idFromName(counterId);
// Ottiene lo stub per comunicare con l'istanza
const stub = env.COUNTER.get(id);
// Invia la richiesta al Durable Object
// La richiesta viene instradata al datacenter corretto automaticamente
return stub.fetch(request);
},
};
interface Env {
COUNTER: DurableObjectNamespace;
}
La configurazione wrangler.toml deve dichiarare il binding:
# wrangler.toml
name = "counter-worker"
main = "src/worker.ts"
compatibility_date = "2024-09-23"
compatibility_flags = ["nodejs_compat"]
[[durable_objects.bindings]]
name = "COUNTER"
class_name = "CounterDO"
[[migrations]]
tag = "v1"
new_classes = ["CounterDO"]
WebSocket con Durable Objects: Chat Room
Il caso d'uso più potente dei Durable Objects è la gestione di sessioni WebSocket con stato condiviso. Ogni stanza di chat è un'istanza separata del DO, che mantiene la lista di connessioni attive e la cronologia dei messaggi.
Cloudflare supporta la WebSocket Hibernation API: il DO viene "ibernato" quando non ci sono messaggi da processare, riducendo drasticamente i costi (si paga solo per il tempo di elaborazione, non per le connessioni aperte).
// src/chat-room-do.ts
// Durable Object per una stanza di chat con WebSocket Hibernation
export class ChatRoomDO implements DurableObject {
private state: DurableObjectState;
private env: Env;
constructor(state: DurableObjectState, env: Env) {
this.state = state;
this.env = env;
}
async fetch(request: Request): Promise<Response> {
const url = new URL(request.url);
if (url.pathname === '/ws') {
// Verifica che sia una richiesta di upgrade WebSocket
const upgradeHeader = request.headers.get('Upgrade');
if (!upgradeHeader || upgradeHeader !== 'websocket') {
return new Response('Expected WebSocket upgrade', { status: 426 });
}
// Crea la coppia WebSocket server/client
const { 0: clientWs, 1: serverWs } = new WebSocketPair();
// Accetta la connessione tramite la Hibernation API
// Il DO sara ibernato tra i messaggi (no costo di CPU idle)
this.state.acceptWebSocket(serverWs);
// Opzionale: associa metadata alla connessione
// Utile per identificare l'utente nei messaggi successivi
const userId = url.searchParams.get('userId') ?? `anon-${Date.now()}`;
serverWs.serializeAttachment({ userId });
// Invia la storia recente al nuovo utente
const history = (await this.state.storage.get<Message[]>('history')) ?? [];
if (history.length > 0) {
serverWs.send(JSON.stringify({ type: 'history', messages: history }));
}
return new Response(null, {
status: 101,
webSocket: clientWs,
});
}
if (url.pathname === '/messages' && request.method === 'GET') {
const history = (await this.state.storage.get<Message[]>('history')) ?? [];
return Response.json({ messages: history });
}
return new Response('Not Found', { status: 404 });
}
// Chiamato dalla Hibernation API quando arriva un messaggio WebSocket
async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise<void> {
const { userId } = ws.deserializeAttachment() as { userId: string };
let parsed: ClientMessage;
try {
parsed = JSON.parse(message as string);
} catch {
ws.send(JSON.stringify({ type: 'error', error: 'Invalid JSON' }));
return;
}
if (parsed.type === 'chat') {
const msg: Message = {
id: crypto.randomUUID(),
userId,
text: parsed.text,
timestamp: Date.now(),
};
// Salva nella history (mantieni solo gli ultimi 100 messaggi)
const history = (await this.state.storage.get<Message[]>('history')) ?? [];
const newHistory = [...history, msg].slice(-100);
await this.state.storage.put('history', newHistory);
// Broadcast a tutte le connessioni WebSocket attive nel DO
const allWebSockets = this.state.getWebSockets();
const payload = JSON.stringify({ type: 'message', message: msg });
for (const socket of allWebSockets) {
try {
socket.send(payload);
} catch {
// Connessione chiusa, ignorala
}
}
}
}
// Chiamato quando una connessione WebSocket viene chiusa
async webSocketClose(ws: WebSocket, code: number, reason: string): Promise<void> {
const { userId } = ws.deserializeAttachment() as { userId: string };
ws.close(code, reason);
// Notifica gli altri utenti dell'uscita
const notification = JSON.stringify({
type: 'user_left',
userId,
timestamp: Date.now(),
});
for (const socket of this.state.getWebSockets()) {
try {
socket.send(notification);
} catch { /* ignore */ }
}
}
// Chiamato in caso di errore sulla connessione WebSocket
async webSocketError(ws: WebSocket, error: unknown): Promise<void> {
console.error('WebSocket error:', error);
ws.close(1011, 'Internal error');
}
}
interface Message {
id: string;
userId: string;
text: string;
timestamp: number;
}
interface ClientMessage {
type: 'chat' | 'ping';
text?: string;
}
interface Env {
CHAT_ROOM: DurableObjectNamespace;
}
Il Worker entry point instrada le richieste alla stanza corretta in base al path:
// src/worker.ts
export { ChatRoomDO } from './chat-room-do';
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
// /room/:roomId/ws -> WebSocket per la stanza
// /room/:roomId/messages -> GET cronologia
const match = url.pathname.match(/^\/room\/([^/]+)(\/.*)?$/);
if (!match) {
return new Response('Not Found', { status: 404 });
}
const roomId = match[1];
const subpath = match[2] ?? '/ws';
// Ogni stanza e una istanza distinta del DO
const id = env.CHAT_ROOM.idFromName(roomId);
const stub = env.CHAT_ROOM.get(id);
// Rewrite del path per il DO
const doUrl = new URL(request.url);
doUrl.pathname = subpath;
return stub.fetch(new Request(doUrl.toString(), request));
},
};
interface Env {
CHAT_ROOM: DurableObjectNamespace;
}
Storage Transazionale: Operazioni Atomiche
Lo storage dei Durable Objects supporta transazioni atomiche tramite
state.storage.transaction(). Questo è fondamentale quando
un'operazione deve leggere e scrivere più chiavi in modo coerente:
// Esempio: trasferimento di crediti tra utenti (atomico)
export class AccountDO implements DurableObject {
state: DurableObjectState;
constructor(state: DurableObjectState, env: Env) {
this.state = state;
}
async fetch(request: Request): Promise<Response> {
if (request.method !== 'POST') {
return new Response('Method Not Allowed', { status: 405 });
}
const { from, to, amount } = await request.json<TransferRequest>();
try {
// La transazione e atomica: o tutto va a buon fine, o niente
await this.state.storage.transaction(async (txn) => {
const fromBalance = (await txn.get<number>(`balance:${from}`)) ?? 0;
const toBalance = (await txn.get<number>(`balance:${to}`)) ?? 0;
if (fromBalance < amount) {
// Il throw annulla la transazione
throw new Error(`Insufficient balance: ${fromBalance} < ${amount}`);
}
await txn.put(`balance:${from}`, fromBalance - amount);
await txn.put(`balance:${to}`, toBalance + amount);
// Log dell'operazione
const txLog = (await txn.get<TxRecord[]>('tx_log')) ?? [];
txLog.push({ from, to, amount, timestamp: Date.now() });
await txn.put('tx_log', txLog.slice(-1000));
});
return Response.json({ success: true, from, to, amount });
} catch (err) {
return Response.json(
{ success: false, error: (err as Error).message },
{ status: 400 }
);
}
}
}
interface TransferRequest {
from: string;
to: string;
amount: number;
}
interface TxRecord {
from: string;
to: string;
amount: number;
timestamp: number;
}
interface Env {
ACCOUNT: DurableObjectNamespace;
}
Alarms: Operazioni Schedulate nel Durable Object
I Durable Objects supportano gli Alarms: una callback
alarm() che viene invocata a un'ora schedulata, anche se non ci sono
richieste attive al DO. Questo è utile per TTL, scadenze e job periodici
legati allo stato del DO:
// src/session-do.ts
// DO con alarm per scadenza automatica della sessione
export class SessionDO implements DurableObject {
state: DurableObjectState;
static SESSION_TTL_MS = 30 * 60 * 1000; // 30 minuti
constructor(state: DurableObjectState, env: Env) {
this.state = state;
}
async fetch(request: Request): Promise<Response> {
const url = new URL(request.url);
if (url.pathname === '/create' && request.method === 'POST') {
const data = await request.json<SessionData>();
// Salva i dati della sessione
await this.state.storage.put('session', {
...data,
createdAt: Date.now(),
});
// Schedula l'alarm per la scadenza della sessione
// Se l'alarm e gia schedulato, viene sostituito
await this.state.storage.setAlarm(Date.now() + SessionDO.SESSION_TTL_MS);
return Response.json({ ok: true, expiresIn: SessionDO.SESSION_TTL_MS });
}
if (url.pathname === '/get') {
const session = await this.state.storage.get<SessionData>('session');
if (!session) {
return Response.json({ error: 'Session not found' }, { status: 404 });
}
// Refresh del TTL ad ogni accesso (sliding expiry)
await this.state.storage.setAlarm(Date.now() + SessionDO.SESSION_TTL_MS);
return Response.json({ session });
}
if (url.pathname === '/invalidate' && request.method === 'DELETE') {
await this.state.storage.deleteAll();
await this.state.storage.deleteAlarm();
return Response.json({ ok: true });
}
return new Response('Not Found', { status: 404 });
}
// Chiamato automaticamente quando scatta l'alarm
async alarm(): Promise<void> {
// Pulisce i dati della sessione scaduta
const session = await this.state.storage.get<SessionData>('session');
if (session) {
console.log(`Session expired for user: ${session.userId}`);
await this.state.storage.deleteAll();
}
}
}
interface SessionData {
userId: string;
role: string;
metadata?: Record<string, unknown>;
}
interface Env {
SESSION: DurableObjectNamespace;
}
Rate Limiter Globale con Durable Objects
Un rate limiter distribuito è uno dei pattern più richiesti per API pubbliche. Con Workers KV l'implementazione sarebbe soggetta a race condition. Con un DO, ogni "bucket" di rate limiting è un'istanza con consistenza forte:
// src/rate-limiter-do.ts
// Token bucket rate limiter con Durable Objects
export class RateLimiterDO implements DurableObject {
state: DurableObjectState;
// Configurazione: 100 req/minuto per IP
static MAX_TOKENS = 100;
static REFILL_RATE_MS = 60_000; // 1 minuto per refill completo
constructor(state: DurableObjectState, env: Env) {
this.state = state;
}
async fetch(request: Request): Promise<Response> {
const now = Date.now();
// Legge lo stato attuale del bucket
const bucket = (await this.state.storage.get<TokenBucket>('bucket')) ?? {
tokens: RateLimiterDO.MAX_TOKENS,
lastRefill: now,
};
// Calcola quanti token sono stati aggiunti dall'ultimo accesso
const elapsed = now - bucket.lastRefill;
const tokensToAdd = (elapsed / RateLimiterDO.REFILL_RATE_MS) * RateLimiterDO.MAX_TOKENS;
const currentTokens = Math.min(
RateLimiterDO.MAX_TOKENS,
bucket.tokens + tokensToAdd
);
if (currentTokens < 1) {
// Rate limit superato
const retryAfterMs = Math.ceil(
(1 - currentTokens) / (RateLimiterDO.MAX_TOKENS / RateLimiterDO.REFILL_RATE_MS)
);
await this.state.storage.put('bucket', {
tokens: currentTokens,
lastRefill: now,
});
return Response.json(
{
allowed: false,
retryAfter: Math.ceil(retryAfterMs / 1000),
remaining: 0,
},
{
status: 429,
headers: {
'Retry-After': String(Math.ceil(retryAfterMs / 1000)),
'X-RateLimit-Limit': String(RateLimiterDO.MAX_TOKENS),
'X-RateLimit-Remaining': '0',
},
}
);
}
// Consuma un token e aggiorna il bucket
await this.state.storage.put('bucket', {
tokens: currentTokens - 1,
lastRefill: now,
});
return Response.json({
allowed: true,
remaining: Math.floor(currentTokens - 1),
});
}
}
interface TokenBucket {
tokens: number;
lastRefill: number;
}
interface Env {
RATE_LIMITER: DurableObjectNamespace;
}
Il Worker integra il rate limiter nel flow di ogni richiesta:
// src/worker.ts con rate limiting
export { RateLimiterDO } from './rate-limiter-do';
export default {
async fetch(request: Request, env: Env): Promise<Response> {
// Identifica il client (IP o API key)
const clientIp = request.headers.get('CF-Connecting-IP') ?? 'unknown';
const apiKey = request.headers.get('X-API-Key');
const bucketId = apiKey ?? `ip:${clientIp}`;
// Controlla il rate limit per questo client
const rateLimiterId = env.RATE_LIMITER.idFromName(bucketId);
const rateLimiter = env.RATE_LIMITER.get(rateLimiterId);
const limitCheck = await rateLimiter.fetch(new Request('https://dummy/check'));
if (limitCheck.status === 429) {
return limitCheck; // Propaga la risposta 429 con gli headers
}
// Prosegue con la logica dell'API
return handleApiRequest(request, env);
},
};
async function handleApiRequest(request: Request, env: Env): Promise<Response> {
return Response.json({ data: 'your api response here' });
}
interface Env {
RATE_LIMITER: DurableObjectNamespace;
}
Considerazioni di Performance e Costi
I Durable Objects hanno un profilo di costi e performance molto diverso rispetto ai semplici Workers. Alcuni punti chiave da tenere a mente:
Latenza: Non Sempre Locale
Ogni istanza DO risiede in un unico datacenter. Se un utente in Europa
accede a un DO instanziato in Nord America, la latenza di ogni operazione
include il round-trip transatlantico (~100-150ms). Progetta gli ID dei DO
per limitare la condivisione geografica: usa ID basati su region quando possibile
(idFromName(`${region}:${resourceId}`)), oppure accetta la latenza
elevata solo per operazioni che richiedono consistenza forte cross-regionale.
| Voce | Free tier | Paid (Workers Paid) |
|---|---|---|
| Richieste al DO | 1M/mese incluse | $0.15 per milione oltre |
| Durata DO (CPU) | 400.000 GB-s/mese | $12.50 per milione GB-s |
| Storage | 1GB incluso | $0.20/GB-mese oltre |
| WebSocket Hibernation | Disponibile | Disponibile (no costo idle) |
| Alarms | Disponibili | Disponibili |
Best Practices
-
Granularità degli ID: usa ID specifici (es.
chat:room-42) piuttosto che ID globali per evitare hotspot su singole istanze. -
WebSocket Hibernation sempre: usa
state.acceptWebSocket()invece di event listener manuali per ridurre i costi delle connessioni idle. -
Storage batch: usa
storage.put(map)per scrivere più chiavi in un'unica operazione invece di multipleput()singole. - Limita la dimensione storage: ogni istanza ha un limite di 128MB di storage. Per dati grandi usa R2 e salva solo riferimenti nel DO.
- Serialization budget: le richieste vengono accodate e processate sequenzialmente; operazioni lente bloccano quelle successive. Mantieni i handler veloci (<1s ideale).
Conclusioni e Prossimi Passi
I Durable Objects colmano il divario tra il modello stateless dei Workers e le applicazioni moderne che richiedono coordinazione e stato condiviso. La loro combinazione con la WebSocket Hibernation API e gli Alarms li rende una primitiva completa per chat, gaming, document collaboration e sistemi di coordinazione globale.
Il compromesso è la latenza: un DO è fisicamente in un solo datacenter, quindi le operazioni di scrittura cross-regionale aggiungono latenza. Per letture scalabili considera di combinare DO (per le scritture) con KV (per le letture cached).
Prossimi Articoli della Serie
- Articolo 5: Workers AI - Inferenza di LLM e Modelli Vision sull'Edge: come eseguire Llama, Whisper e modelli vision direttamente nei Workers senza GPU dedicata, con la crescita del 4000% YoY di Workers AI.
- Articolo 6: Vercel Edge Runtime - Middleware Avanzato, Geolocalizzazione e A/B Testing: l'approccio di Vercel all'edge con Next.js.
- Articolo 7: Routing Geografico all'Edge - Personalizzazione Contenuti e Compliance GDPR: geo-fencing, prezzi localizzati e GDPR.

Commenti
Caricamento commenti...
Accedi per commentare