Cloudflare
Durable Objects
Programação
Estado
Concorrência

O modelo de programação dos Durable Objects: o que é diferente de tudo que você já usou

Como o modelo de programação dos Durable Objects difere de qualquer coisa em serverless convencional, com as APIs de storage, transaction e Alarm que mudam o que é possível.

A maioria dos engenheiros que encontra Durable Objects pela primeira vez os lê como "Workers com banco de dados embutido" e começa a escrever código nessa direção. O resultado são aplicações que funcionam nos testes e falham de formas sutis em produção. O modelo mental está errado porque a diferença entre um DO e um Worker com acesso a banco não é de conveniência — é de onde o estado vive e de quem controla o acesso a ele.

A estrutura de uma classe Durable Object

Um DO é uma classe JavaScript com três elementos centrais: o construtor, o método fetch(), e acesso a this.ctx.storage. O construtor roda uma vez quando o DO é criado ou acorda da hibernação. O fetch() processa cada requisição recebida. E this.ctx.storage é a API de armazenamento durável que persiste estado entre requisições e entre hibernações.

export class Counter implements DurableObject { private count: number = 0; constructor(private ctx: DurableObjectState, private env: Env) { this.ctx.blockConcurrencyWhile(async () => { this.count = (await this.ctx.storage.get<number>('count')) ?? 0; }); } async fetch(request: Request): Promise<Response> { this.count++; await this.ctx.storage.put('count', this.count); return new Response(String(this.count)); } }

O blockConcurrencyWhile() no construtor é necessário quando você precisa hidratar estado da storage antes de processar qualquer requisição. Sem ele, uma requisição que chegasse antes do await da storage ser resolvido veria um estado incompleto. O blockConcurrencyWhile enfileira requisições até que o callback resolva — é a forma de inicializar o DO de forma segura.

O que muda quando o estado vive em memória

A operação "ler do banco, computar, escrever de volta" é o padrão mais comum em aplicações server-side e também a fonte de condições de corrida mais comuns. Entre o read e o write, outro processo pode ter modificado o mesmo registro. Para resolver isso com um banco convencional, você usa transações com locks, SELECT FOR UPDATE, ou alguma forma de versioning otimista.

Em um DO, esse problema não existe da mesma forma. O estado vive em memória — this.count já tem o valor atual. Não há round-trip para ler. O incremento e a persistência acontecem dentro da mesma execução, sem outra requisição podendo intercalar. A garantia não é do banco: é da execução serial do próprio DO.

Isso tem um custo que precisa ser explícito: a consistência depende de você persistir o estado que modificou em memória. Se o DO hiberna antes de um put() ser chamado, a modificação em memória se perde. Toda mudança que importa precisa ser persistida na storage antes da requisição terminar. O padrão correto é modificar e persistir na mesma operação, sem depender de um flush posterior.

A API de storage e quando usar transaction()

A API de storage tem operações diretas: get(), put(), delete(), list(). Todas são atômicas individualmente e duráveis — o que foi escrito está garantido em disco mesmo que o DO hiberne imediatamente após. A linearizabilidade vale para toda a storage de uma instância: qualquer leitura posterior ao put() vai ver o valor escrito, sem exceção.

Quando uma operação lógica modifica múltiplas chaves e todas precisam ser consistentes entre si, transaction() é a ferramenta correta:

await this.ctx.storage.transaction(async (txn) => { const balance = await txn.get<number>('balance') ?? 0; await txn.put('balance', balance - amount); await txn.put('last_debit', Date.now()); await txn.put('debit_count', (await txn.get<number>('debit_count') ?? 0) + 1); });

Se o callback de transaction() lança uma exceção, nenhuma das escritas é persistida. O estado da storage volta ao que era antes do início da transação. Não existe commit parcial. Isso substitui a necessidade de compensating transactions ou de lógica de rollback na aplicação para operações que afetam múltiplas chaves.

Um detalhe operacional de list(): por padrão retorna no máximo 128 entradas. Para conjuntos maiores, use o cursor retornado na resposta para paginar. Ignorar isso é descobrir o limite em produção com dados reais.

A Alarm API: setTimeout que sobrevive à hibernação

Workers têm setTimeout(), mas o timer não sobrevive à hibernação do isolate. Quando o Worker termina de processar a requisição, qualquer timer pendente desaparece. Para um DO que precisa executar algo num ponto futuro — purgar sessões expiradas, reenviar uma mensagem que falhou, invalidar um lock após timeout — a Alarm API é o mecanismo correto:

async fetch(request: Request): Promise<Response> { const body = await request.json() as { lockKey: string; ttlMs: number }; await this.ctx.storage.put(`lock:${body.lockKey}`, true); await this.ctx.storage.setAlarm(Date.now() + body.ttlMs); return new Response('lock acquired'); } async alarm(): Promise<void> { const keys = await this.ctx.storage.list({ prefix: 'lock:' }); for (const key of keys.keys()) { await this.ctx.storage.delete(key); } }

setAlarm() recebe um timestamp absoluto em milissegundos. Quando o DO hiberna, a Cloudflare mantém o alarme agendado. Na hora certa, o DO acorda e o método alarm() é invocado. O custo é $0.15/milhão de invocações — a mesma tabela de preços das requisições normais.

Só é possível ter um alarme ativo por DO por vez. setAlarm() substitui o anterior. Se você precisa de múltiplos timers, a estratégia é armazenar na storage a lista de eventos futuros, agendar um alarme para o mais próximo, e quando alarm() roda, processar os eventos vencidos e reagendar para o próximo.

Como o roteamento para a instância certa funciona

Durable Objects têm três estratégias de geração de ID, e a escolha determina o comportamento de roteamento.

idFromName("room-123") é determinístico: o mesmo nome sempre produz o mesmo ID, globalmente. É a estratégia para quando você quer que todos os clientes que pedem "room-123" cheguem ao mesmo DO. O ID é derivado por hash do nome, e dois DOs com o mesmo nome no mesmo namespace são o mesmo objeto.

newUniqueId() gera um ID aleatório — sempre um DO novo. Use quando você cria uma entidade e vai armazenar o ID para referência futura, sem precisar derivá-lo de uma chave.

idFromString(hexStr) reconstrói um ID a partir de uma string hexadecimal que você armazenou anteriormente. Útil quando o ID foi gerado com newUniqueId(), persistido num banco externo ou no KV, e você precisa referenciar o mesmo DO mais tarde.

Location hints permitem sugerir uma jurisdição geográfica (EU, US), mas não garantem um PoP específico. O DO é criado no PoP mais próximo da primeira requisição que o instanciou. Para aplicações com requisito de residência de dados na EU, o locationHint: 'eu' direciona a criação para um PoP europeu, mas a Cloudflare escolhe qual.

Como isso se conecta ao Worker que roteia

O Worker que recebe a requisição do cliente precisa obter o stub do DO e repassar a requisição:

export default { async fetch(request: Request, env: Env): Promise<Response> { const roomId = new URL(request.url).searchParams.get('room') ?? 'default'; const id = env.ROOMS.idFromName(roomId); const stub = env.ROOMS.get(id); return stub.fetch(request); } };

O stub tem o mesmo contrato de fetch() que um Worker normal. O Worker que roteia não tem acesso à storage do DO, não pode ler seu estado interno, não pode chamar métodos além do fetch() e do RPC (quando configurado com WorkerEntrypoint). A comunicação é por requisições HTTP normais.

Durable Objects não são uma abstração de banco de dados. São um modelo de computação com estado colocado. Tratá-los como banco de dados que também processa lógica, em vez de um objeto com identidade e estado, é o que leva à confusão — e ao código que funciona em produção por acidente, não por design.

Leia também

O modelo de programação dos Durable Objects: o que é diferente de tudo que você já usou | Matheus Breguêz