Um Guia para Iniciantes sobre Cloudflare Durable Objects
Cloudflare Durable Objects explicado: roteamento, estado de instância única, armazenamento SQLite e exemplo TypeScript de rate limiter com Workers.
Um Durable Object é uma única instância de uma classe JavaScript que a Cloudflare executa em exatamente um lugar por vez; toda requisição que nomeia essa instância é roteada até ela, independentemente de onde no mundo a requisição tenha se originado, e a instância carrega seu próprio armazenamento privado.
Construa um contador, um lock ou uma lista de “quem está nesta sala” usando Workers puros e duas requisições podem acabar discordando entre si. O Worker que tratou a primeira requisição e o Worker que tratou a segunda podem ser isolates diferentes em cidades diferentes, sem memória compartilhada entre eles.
Este guia explica o modelo de roteamento que faz os Durable Objects funcionarem e, em seguida, mostra o menor código TypeScript que demonstra isso, usando um rate limiter por chave de API como exemplo único ao longo do texto. Ele pressupõe que você já conhece bindings, wrangler e o handler fetch; se você precisa desse embasamento antes, comece pelo guia para iniciantes do OpenReplay sobre Cloudflare Workers.
Principais Conclusões
- Durable Objects são uma primitiva de computação que carrega seu próprio armazenamento privado, não um produto de armazenamento que você lê a partir de um Worker; o armazenamento só é acessível a partir de código executando dentro do objeto.
- A string passada para
env.BINDING.getByName(name)é a identidade do objeto: toda requisição em toda a rede da Cloudflare que passa a mesma string alcança a mesma instância em execução. - Cada Durable Object possui um banco de dados SQLite embarcado na mesma thread do seu código, então
this.ctx.storage.sql.exec()retorna um cursor de forma síncrona e não precisa deawait. - Campos de classe sobrevivem entre requisições consecutivas, mas são descartados quando o objeto hiberna após cerca de 10 segundos de inatividade; tudo que precisa sobreviver pertence a
ctx.storage. - Use Workers KV quando muitas localidades leem os mesmos dados e uma escrita pode levar tempo para se tornar visível em todos os lugares; use um Durable Object quando vários clientes precisam concordar sobre o valor atual no mesmo instante.
Por Que Workers Stateless Falham na Coordenação?
Um Worker não guarda nada entre requisições. Duas chamadas podem cair em isolates diferentes, em lugares diferentes, e nenhuma consegue ver o que a outra fez, então qualquer funcionalidade que exija que requisições consecutivas concordem sobre um valor quebra. A própria orientação de design da Cloudflare para Durable Objects traça exatamente essa linha entre Workers stateless e coordenação stateful.
Aqui está o rate limiter ingênuo que parece correto e não é:
// Broken: this Map exists per isolate. Another isolate has its own copy.
const hits = new Map<string, number>();
export default {
async fetch(request): Promise<Response> {
const key = request.headers.get("x-api-key") ?? "anonymous";
const count = (hits.get(key) ?? 0) + 1;
hits.set(key, count);
return new Response(count > 10 ? "slow down" : "ok", {
status: count > 10 ? 429 : 200,
});
},
} satisfies ExportedHandler;
O Map no nível do módulo vive em um único isolate. Um cliente enviando 30 requisições que caem em três isolates vê três contadores independentes que param em 10 cada um, e o limite nunca é aplicado. Mover o contador para um banco de dados externo resolve o compartilhamento, mas introduz uma condição de corrida entre leitura e escrita em requisições concorrentes. O problema não é onde os dados ficam; é que nada garante um único lugar onde a verificação e a atualização aconteçam juntas.
A Ideia Central: Um Objeto por Nome, a Mesma Instância Sempre
Durable Objects resolvem a coordenação dando a cada nome exatamente uma instância em execução e roteando toda requisição para aquele nome até ela. A página de conceitos apresenta três propriedades por trás disso: cada objeto responde a um nome que é único mundialmente, seu armazenamento fica junto dele e não do outro lado de uma rede, e ele executa uma coisa por vez, do mesmo modo que o JavaScript em uma aba de navegador.
Três propriedades decorrem desse modelo:
- A identidade é o nome. Seu Worker escolhe uma string (uma chave de API, um ID de sala, um ID de documento) e a plataforma a mapeia para uma instância. Dois Workers em continentes diferentes passando a mesma string conversam com o mesmo objeto.
- A criação é implícita. Não existe chamada de criação. A referência da API de namespace explica que um ID por si só não cria nada, e que objetos não são construídos até que algo efetivamente os alcance. Na prática, o construtor executa quando chega a primeira chamada de método no stub.
- A execução é single-threaded. Código síncrono dentro de um método não pode ser interrompido por outra requisição. Outras requisições só podem executar enquanto seu código está aguardando I/O que não seja de armazenamento, como
fetch().
Como cada objeto é uma thread em uma máquina, a vazão escala horizontalmente e não verticalmente: um rate limiter deve ser um objeto por chave de API, nunca um objeto global para todo o tráfego.
Como Definir e Registrar uma Classe Durable Object?
Um Durable Object é uma classe exportada que estende DurableObject de cloudflare:workers, recebe ctx e env em seu construtor e expõe seus métodos públicos aos Workers por RPC. O guia de introdução fixa a assinatura do construtor como (ctx: DurableObjectState, env: Env) com uma chamada obrigatória a super(ctx, env).
import { DurableObject } from "cloudflare:workers";
export class RateLimiter extends DurableObject<Env> {
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
this.ctx.storage.sql.exec(
"CREATE TABLE IF NOT EXISTS hits (bucket INTEGER PRIMARY KEY, count INTEGER NOT NULL)"
);
}
async increment(limit: number, windowMs: number): Promise<{ allowed: boolean; remaining: number }> {
const bucket = Math.floor(Date.now() / windowMs);
// No await between the read and the write: nothing else can run in between.
const row = this.ctx.storage.sql
.exec<{ count: number }>("SELECT count FROM hits WHERE bucket = ?", bucket)
.toArray()[0];
const count = row ? row.count + 1 : 1;
this.ctx.storage.sql.exec(
"INSERT INTO hits (bucket, count) VALUES (?, ?) ON CONFLICT(bucket) DO UPDATE SET count = ?",
bucket, count, count
);
return { allowed: count <= limit, remaining: Math.max(0, limit - count) };
}
}
O SELECT e o upsert executam sem nenhum await entre eles, então nenhuma outra requisição para essa chave de API consegue se infiltrar no meio. Esse único fato é o que a versão stateless não conseguia oferecer.
Registrar a classe exige duas entradas em wrangler.jsonc. A entrada exports marca a classe como um durable-object com armazenamento sqlite e é o que provisiona o namespace no primeiro deploy. A entrada durable_objects.bindings dá ao Worker um handle através de env:
{
"durable_objects": {
"bindings": [
{ "name": "RATE_LIMITER", "class_name": "RateLimiter" }
]
},
"exports": {
"RateLimiter": {
"type": "durable-object",
"storage": "sqlite"
}
}
}
Exemplos mais antigos registram classes através de um array migrations com new_sqlite_classes. Essa forma ainda é suportada para Workers existentes, mas exports é o método atual e os dois não podem coexistir em um mesmo arquivo de configuração.
Como Chamar um Durable Object a Partir de um Worker?
Um Worker alcança um Durable Object pedindo ao binding um stub com getByName(name) e então chamando os métodos públicos da classe nesse stub como funções assíncronas comuns. Um stub é apenas um handle local: as chamadas feitas nele são encaminhadas para a única instância que detém aquele nome.
export default {
async fetch(request, env): Promise<Response> {
const key = request.headers.get("x-api-key") ?? "anonymous";
// `key` is the object's identity. Same key, same instance, everywhere.
const stub = env.RATE_LIMITER.getByName(key);
const { allowed, remaining } = await stub.increment(10, 60_000);
return new Response(allowed ? "ok" : "slow down", {
status: allowed ? 200 : 429,
headers: { "x-ratelimit-remaining": String(remaining) },
});
},
} satisfies ExportedHandler<Env>;
A string passada para getByName() é a chave de roteamento. getByName(name) é uma forma abreviada do antigo processo em duas etapas idFromName(name) seguido de get(id), que você ainda verá em muitos exemplos; ambos endereçam o mesmo objeto. Chamar métodos diretamente no stub como RPC exige uma data de compatibilidade de 2024-04-03 ou posterior, o que qualquer template novo satisfaz.
Onde um Durable Object Armazena Seu Estado?
Cada Durable Object tem dois tipos de estado: um banco de dados SQLite embarcado e privado que sobrevive a reinícios, e campos de classe comuns que vivem apenas enquanto o objeto está em memória. A API de armazenamento SQLite executa na mesma thread do seu código, então exec() devolve um SqlStorageCursor imediatamente, sem await. Esvazie esse cursor antes do próximo await, usando .toArray(), .one() ou um laço: um cursor deixado aberto ao longo de um await pode capturar linhas escritas nesse meio-tempo, incluindo escritas que depois sofrem rollback.
Um campo de classe é o caminho rápido. Adicionar um ao rate limiter mostra a diferença:
export class RateLimiter extends DurableObject<Env> {
// In-memory: fast, private to this instance, gone after hibernation.
private lastSeen = 0;
async increment(limit: number, windowMs: number) {
this.lastSeen = Date.now();
// ... SQLite read and write as before (durable)
}
}
lastSeen sobrevive entre requisições consecutivas, mas a documentação de ciclo de vida afirma que, após 10 segundos sem eventos de entrada (e sem timers pendentes, WebSockets de API padrão ou fetch() em andamento), o objeto hiberna e sua memória é descartada. Deploys e manutenção de runtime também podem reiniciá-lo a qualquer momento. A tabela hits sobrevive a tudo isso. Cada objeto com backend SQLite pode armazenar até 10 GB no plano Workers Paid, conforme a página de limites.
Você Deve Usar Workers KV ou Durable Objects?
Use Workers KV quando muitas localidades precisam ler os mesmos dados e é aceitável que uma escrita leve tempo para se tornar visível em todos os lugares; use um Durable Object quando vários clientes precisam concordar sobre o valor atual no mesmo instante. A documentação de consistência do KV é direta sobre o trade-off: o KV abre mão de consistência em favor de velocidade, uma mudança pode levar um minuto ou mais para aparecer em outras localidades, e qualquer coisa que exija leitura e escrita atômicas em conjunto deve usar Durable Objects.
| Workers KV | Durable Objects | |
|---|---|---|
| Consistência | Eventual; cópias em cache expiram por TTL | Forte; uma única instância detém os dados |
| Onde ocorrem as leituras | Qualquer localidade, a partir do cache | Dentro da única instância proprietária |
| Segurança de leitura-e-escrita | Nenhuma entre requisições | Garantida sem await intermediário fora do armazenamento |
| Padrão de escrita | Escritas pouco frequentes por chave | Escritas por objeto serializadas pelo runtime |
| Uso típico | Configuração, feature flags, allow-lists | Contadores, locks, salas, estado por entidade |
A comparação de opções de armazenamento classifica os dois da mesma maneira: o KV cobre configuração e valores semelhantes que são lidos muito mais vezes do que mudam, enquanto os Durable Objects cobrem coordenação entre clientes e armazenamento que permanece consistente por objeto. Um rate limiter é um contador de leitura-e-escrita, então ele pertence a um Durable Object. Uma feature flag por tenant lida a cada requisição pertence ao KV.
Conclusão
Durable Objects resolvem a coordenação eliminando a questão de onde o estado vive: o nome que você passa para getByName() seleciona exatamente uma instância em execução, seu banco de dados SQLite fica na mesma thread do seu código, e uma leitura seguida de uma escrita sem nenhum await fora do armazenamento entre elas não pode ser intercalada. O próximo passo é criar o esqueleto do template Worker + Durable Objects com npm create cloudflare@latest, substituir a classe gerada pelo rate limiter acima e executar npx wrangler dev para ver a contagem subir ao longo de requisições que, de outra forma, nunca concordariam entre si.
Perguntas Frequentes
Qual é a diferença entre Durable Objects e D1?
O D1 é um banco de dados SQLite gerenciado que seu Worker consulta pela rede, com uma API HTTP e migrações de schema integradas. O banco de dados SQLite de um Durable Object roda na mesma máquina que o código do objeto e só é acessível a partir de Workers através desse objeto. Ambos têm limite de 10 GB por banco de dados no plano Workers Paid, e ambos têm limites menores no plano gratuito. Use o D1 para um único banco de dados relacional compartilhado; use Durable Objects para estado por usuário ou por entidade que precisa de coordenação.
Quantas requisições por segundo um único Durable Object consegue processar?
Um único Durable Object tem um limite flexível de cerca de 1.000 requisições por segundo, porque cada objeto roda em uma thread em uma máquina. Acima disso, o runtime enfileira o que consegue e então falha as chamadas excedentes com um erro de sobrecarga. Cada invocação recebe 30 segundos de tempo de CPU por padrão, configurável até 5 minutos com limits.cpu_ms na configuração do Wrangler. Escale horizontalmente com um objeto por nome, como um objeto por chave de API.
Durable Objects funcionam no plano gratuito do Workers?
Sim. Durable Objects com o backend de armazenamento SQLite estão disponíveis no plano Workers Free, limitados a 1 GB por objeto, 5 GB de armazenamento total de Durable Objects por conta e 100 classes de Durable Object. O Workers Paid eleva esses valores para 10 GB por objeto, armazenamento ilimitado por conta e 500 classes. Quando um objeto fica cheio, as escritas falham com um erro SQLITE_FULL, embora você ainda possa ler linhas e apagá-las para liberar espaço.
Preciso de transações explícitas com sql.exec() em um Durable Object?
Normalmente não. Cada chamada a sql.exec() já executa dentro de sua própria transação, e leituras e escritas que se sucedem sem nenhum await entre elas são confirmadas como um único lote atômico, então a sequência leitura-e-escrita em um rate limiter é segura como está escrita. sql.exec() não pode executar instruções BEGIN TRANSACTION ou SAVEPOINT. Para agrupar várias instruções de modo que todas sofram rollback se uma lançar exceção, use ctx.storage.transactionSync(callback). O callback precisa ser totalmente síncrono: não declarado como async e sem retornar Promise.
Gain Debugging Superpowers
Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.
Star on GitHub12k