Durable Objects
Stateful compute at the edge. The foundation for z0 per-entity ledgers.
Prerequisites: PRINCIPLES.md, PRIMITIVES.md
Overview
Section titled “Overview”Durable Objects (DOs) are Cloudflare’s solution for strongly consistent, stateful compute at the edge. Each DO is a single-threaded JavaScript object with durable storage that lives at the edge.
| Property | Implication for z0 |
|---|---|
| Single-threaded | No race conditions. Config versioning is serialized per config_id. |
| Globally unique | One DO per entity_id. Single source of truth. |
| Durable storage | SQLite per DO. Facts survive restarts. |
| Edge-located | Low latency reads. RTB hot path can stay fast. |
| Alarm support | Background work without external cron. Reconciliation, replication. |
Key Insight: DOs give us single-writer semantics for free. Every Entity gets its own DO, so concurrent writes to the same Entity serialize naturally.
How z0 Uses Durable Objects
Section titled “How z0 Uses Durable Objects”Per-Entity Ledgers
Section titled “Per-Entity Ledgers”Each Entity with a ledger (account, asset, contact, deal) gets its own DO containing:
- The Entity record — current state
- Facts ledger — append-only log of Facts involving this Entity
- Cached state — BudgetState, CapState, etc.
- Configs — active Configs that govern this Entity
DO: account_acct_acme (DO ID = namespace + entity ID)├── Entity { id: "acct_acme", type: "account", subtype: "tenant", ... }├── Facts[]│ ├── Fact { type: "invocation", ... }│ ├── Fact { type: "outcome", ... }│ ├── Fact { type: "charge", ... }│ └── ...├── CachedState│ ├── BudgetState { remaining: 4500, ... }│ └── PrepaidBalance { balance: 2000, ... }└── Configs[] ├── Config { type: "pricing", version: 3, ... } └── Config { type: "budget", version: 2, ... }Note: Configs are versioned primitives (per PRIMITIVES.md), not cached state. They are stored in the DO for fast access but are authoritative, not derived. Only BudgetState, PrepaidBalance, CapState, etc. are cached state—derived from Facts and disposable.
Why Per-Entity?
Section titled “Why Per-Entity?”| Alternative | Problem |
|---|---|
| Single global DO | Serializes all writes. Doesn’t scale. |
| Per-tenant DO | Hot tenants become bottlenecks. |
| Per-request DO | Loses single-writer benefit. Race conditions return. |
| Per-entity DO | Natural sharding. Writes scale with entities. |
Result: Write throughput scales linearly with entity count. Hot entities (high-volume assets) get dedicated compute.
SQLite Storage
Section titled “SQLite Storage”Each DO has a private SQLite database. This is where Facts live before replication to D1.
Schema Pattern
Section titled “Schema Pattern”-- Entity recordCREATE TABLE entity ( id TEXT PRIMARY KEY, type TEXT NOT NULL, subtype TEXT, data TEXT NOT NULL, -- JSON blob updated_at INTEGER NOT NULL);
-- Facts ledger (append-only)CREATE TABLE facts ( id TEXT PRIMARY KEY, type TEXT NOT NULL, subtype TEXT, timestamp INTEGER NOT NULL, data TEXT NOT NULL, -- JSON blob replicated_at INTEGER -- NULL until replicated to D1);
-- Cached stateCREATE TABLE cached_state ( key TEXT PRIMARY KEY, value TEXT NOT NULL, -- JSON blob computed_at INTEGER NOT NULL, facts_through TEXT -- Last fact_id included in computation);
-- Configs (versioned)CREATE TABLE configs ( id TEXT NOT NULL, version INTEGER NOT NULL, type TEXT NOT NULL, settings TEXT NOT NULL, -- JSON blob effective_at INTEGER NOT NULL, superseded_at INTEGER, PRIMARY KEY (id, version));
CREATE INDEX idx_facts_type ON facts(type);CREATE INDEX idx_facts_timestamp ON facts(timestamp);CREATE INDEX idx_facts_not_replicated ON facts(replicated_at) WHERE replicated_at IS NULL;CREATE INDEX idx_configs_active ON configs(id) WHERE superseded_at IS NULL;Storage Limits
Section titled “Storage Limits”| Resource | Limit | z0 Implication |
|---|---|---|
| SQLite database size | 10 GB | Plenty for hot data. Archive old Facts to R2. |
| Row size | No hard limit | Keep Facts focused. Large payloads go to R2. |
| Concurrent connections | 1 (single-threaded) | By design. No connection pooling needed. |
Query Performance
Section titled “Query Performance”SQLite in DOs is fast for:
- Single-entity queries (it’s the entity’s private DB)
- Recent Facts (indexed by timestamp)
- Current Config lookup (indexed by active)
SQLite in DOs is slow for:
- Cross-entity aggregation (use D1)
- Historical deep queries (use D1)
- Bulk analytics (use Analytics Engine)
Rule: DO SQLite is for hot path operations. D1 is for reporting.
Single-Writer Pattern
Section titled “Single-Writer Pattern”The single-writer pattern is not something we implement—it’s what DOs give us inherently.
How It Works
Section titled “How It Works”Request A: append Fact to account_123Request B: append Fact to account_123
┌──────────────────┐Request A ────────► │ │ ────► Response A │ DO: account_123 │Request B ────────► │ (single thread) │ ────► Response B └──────────────────┘
Requests serialize automatically. B waits for A to complete.Benefits for z0
Section titled “Benefits for z0”| Requirement | How DOs Help |
|---|---|
| Fact ordering | Facts append in arrival order. No conflicts. |
| Config versioning | Version increment is atomic. No lost updates. |
| Budget enforcement | Read-check-write is atomic. No overspend. |
| Cached state updates | Update after Fact append is atomic. |
Code Pattern
Section titled “Code Pattern”// Inside DO classasync appendFact(fact: Fact): Promise<void> { // No locks needed - we're single-threaded
// 1. Validate const currentConfig = await this.getActiveConfig(fact.configType); if (!currentConfig) throw new Error("No active config");
// 2. Append Fact with Config reference fact.config_id = currentConfig.id; fact.config_version = currentConfig.version; await this.sql.exec( `INSERT INTO facts (id, type, subtype, timestamp, data) VALUES (?, ?, ?, ?, ?)`, [fact.id, fact.type, fact.subtype, fact.timestamp, JSON.stringify(fact)] );
// 3. Update cached state await this.updateCachedState(fact);
// 4. Schedule replication (via alarm) await this.scheduleReplication();}Alarm-Based Reconciliation
Section titled “Alarm-Based Reconciliation”DOs support alarms—scheduled wake-ups without external infrastructure.
Use Cases in z0
Section titled “Use Cases in z0”| Alarm Type | Purpose | Schedule |
|---|---|---|
| Replication | Push Facts to D1 | After each write (debounced) |
| Reconciliation | Verify cached state matches Facts | Every 5 minutes |
| Expiration | Archive old Facts to R2 | Daily |
| Health check | Ensure DO is responsive | Every minute |
Replication Alarm Pattern
Section titled “Replication Alarm Pattern”async alarm(): Promise<void> { const unreplicated = await this.sql.exec( `SELECT * FROM facts WHERE replicated_at IS NULL ORDER BY timestamp LIMIT 100` );
if (unreplicated.length === 0) return;
// Batch write to D1 via Queue (for reliability) await this.env.FACT_QUEUE.send({ entity_id: this.entityId, facts: unreplicated });
// Mark as replicated const ids = unreplicated.map(f => f.id); await this.sql.exec( `UPDATE facts SET replicated_at = ? WHERE id IN (${ids.map(() => '?').join(',')})`, [Date.now(), ...ids] );
// Reschedule if more remain const remaining = await this.sql.exec( `SELECT COUNT(*) as count FROM facts WHERE replicated_at IS NULL` ); if (remaining[0].count > 0) { await this.storage.setAlarm(Date.now() + 100); // 100ms }}Reconciliation Alarm Pattern
Section titled “Reconciliation Alarm Pattern”async runReconciliation(): Promise<void> { // Get cached BudgetState const cached = await this.getCachedState('BudgetState');
// Calculate from Facts const calculated = await this.calculateBudgetState();
// Compare if (!deepEqual(cached, calculated)) { // Record reconciliation Fact await this.appendFact({ type: 'reconciliation', subtype: 'mismatch_detected', data: { cache_type: 'BudgetState', cached_value: cached, calculated_value: calculated, delta: diff(cached, calculated), resolution: 'cache_updated' } });
// Fix the cache await this.setCachedState('BudgetState', calculated); }
// Schedule next reconciliation await this.storage.setAlarm(Date.now() + 5 * 60 * 1000); // 5 minutes}When to Use Durable Objects
Section titled “When to Use Durable Objects”Use DOs For
Section titled “Use DOs For”| Use Case | Why |
|---|---|
| Per-entity ledgers | Single-writer guarantees ordering |
| Config versioning | Atomic read-modify-write |
| Budget/cap enforcement | Consistent read-check-write |
| Cached state with reconciliation | State lives with its source Facts |
| RTB hot path reads | Low-latency edge access |
Do Not Use DOs For
Section titled “Do Not Use DOs For”| Use Case | Use Instead | Why |
|---|---|---|
| Cross-tenant reporting | D1 | DOs are per-entity |
| Historical analytics | Analytics Engine | DOs optimize for recent data |
| Large file storage | R2 | SQLite not for blobs |
| High-fanout broadcasts | Queues | DOs don’t parallelize |
| Stateless compute | Workers | DOs have overhead |
Decision Tree
Section titled “Decision Tree”Need consistent writes to same entity?├── Yes → DO│ └── Need cross-entity query?│ ├── Yes → Replicate to D1, query D1│ └── No → Query DO directly└── No → Worker (stateless)Config Scope Resolution
Section titled “Config Scope Resolution”Config scope precedence (from PRIMITIVES.md) is: asset > campaign > account. This section explains how DOs resolve the correct Config at runtime.
Resolution Strategy
Section titled “Resolution Strategy”Config resolution uses a DO-local-first approach with stub calls for parent scopes:
- Check entity’s own DO SQLite for a Config of the requested type
- If not found, call the parent entity’s DO (campaign, then account) via stub
- Cache the resolved Config in the entity’s DO for hot path performance
// Inside AssetDOasync resolveConfig(configType: string, campaignId?: string): Promise<Config> { // 1. Check asset-scoped Config (own SQLite) const assetConfig = await this.getActiveConfig(configType); if (assetConfig) return assetConfig;
// 2. Check campaign-scoped Config (stub call to CampaignDO) if (campaignId) { const campaignDO = this.env.CAMPAIGN_DO.get( this.env.CAMPAIGN_DO.idFromName(`campaign_${campaignId}`) ); const campaignConfig = await campaignDO.getActiveConfig(configType); if (campaignConfig) return campaignConfig; }
// 3. Check account-scoped Config (stub call to AccountDO) const accountDO = this.env.ACCOUNT_LEDGER.get( this.env.ACCOUNT_LEDGER.idFromName(`account_${this.ownerId}`) ); const accountConfig = await accountDO.getActiveConfig(configType); if (accountConfig) return accountConfig;
// 4. No Config found at any scope throw new Error(`No ${configType} Config found for asset ${this.entityId}`);}Why Stub Calls (Not D1)?
Section titled “Why Stub Calls (Not D1)?”| Approach | Pros | Cons | Verdict |
|---|---|---|---|
| Query D1 | Single query, simple | D1 is eventually consistent, adds latency | Not for hot path |
| Replicate all Configs to every DO | Fast reads | Storage bloat, consistency complexity | Not scalable |
| Stub calls to parent DOs | Authoritative, consistent | Multiple round trips | Preferred |
Stub calls to parent DOs are preferred because:
- Configs are authoritative data (not cached state)
- Parent DOs have the source of truth for their scope
- DO-to-DO calls are fast within the same Cloudflare network
- Caching resolved Configs locally mitigates repeated lookups
Caching Resolved Configs
Section titled “Caching Resolved Configs”For RTB hot path performance, resolved Configs can be cached locally:
// Cache structure (not in cached_state table - this is short-lived)private configCache: Map<string, { config: Config; expiresAt: number }>;
async resolveConfigCached(configType: string, campaignId?: string): Promise<Config> { const cacheKey = `${configType}:${campaignId || 'none'}`; const cached = this.configCache.get(cacheKey);
if (cached && cached.expiresAt > Date.now()) { return cached.config; }
const config = await this.resolveConfig(configType, campaignId); this.configCache.set(cacheKey, { config, expiresAt: Date.now() + 60_000 // 1 minute TTL });
return config;}Important: This is a short-lived in-memory cache, not persisted cached state. It exists only to reduce stub call frequency during request bursts. The TTL should be short (seconds to minutes) to pick up Config changes promptly.
Config Change Propagation
Section titled “Config Change Propagation”When a Config is updated at a parent scope:
- Immediate: Parent DO has new version
- On next resolution: Child DOs will fetch new version (cache miss or TTL expiry)
- No push notification: Child DOs discover changes on next lookup
This is acceptable because:
- Config changes are infrequent (minutes to hours between changes)
- Short cache TTL ensures updates propagate within seconds
- Facts record which Config version was applied (audit trail preserved)
Constraints and Limits
Section titled “Constraints and Limits”Request Limits
Section titled “Request Limits”| Limit | Value | Mitigation |
|---|---|---|
| Request duration | 30 seconds | Break long operations into alarms |
| Concurrent requests | Serialized (1 at a time) | By design. Don’t fight it. |
| Requests per second per DO | ~1000 | Shard hot entities if needed |
| Subrequest count | 1000 per request | Batch operations |
Storage Limits
Section titled “Storage Limits”| Limit | Value | Mitigation |
|---|---|---|
| SQLite size | 10 GB | Archive old Facts to R2 |
| Transactional writes | 128 MB | Batch large inserts |
| Storage class | Standard only | Design for durability, not cold storage |
Alarm Limits
Section titled “Alarm Limits”| Limit | Value | Note |
|---|---|---|
| Minimum interval | 0 (immediate) | Can schedule alarm for now |
| Maximum future | 30 days | Reschedule for longer |
| Alarms per DO | 1 at a time | New alarm overwrites old |
Operational Limits
Section titled “Operational Limits”| Limit | Value | Note |
|---|---|---|
| DO classes per account | 100 | Plan class hierarchy carefully |
| Unique DOs | Unlimited | Scale with entities |
| Geographic distribution | Automatic | DOs migrate to callers |
Anti-Patterns
Section titled “Anti-Patterns”1. Global Coordinator DO
Section titled “1. Global Coordinator DO”Wrong:
// One DO to rule them allclass GlobalRouter { async route(request: Request): Promise<Response> { // All routing goes through one DO // Becomes bottleneck at ~1000 RPS }}Right:
// Route to entity-specific DOconst entityDO = env.ENTITY_DO.get(entityId);return entityDO.fetch(request);2. Treating DOs as Caches
Section titled “2. Treating DOs as Caches”Wrong:
// Using DO as a cache layerasync get(key: string): Promise<Value> { return this.cache.get(key); // Adds latency for simple reads}Right:
// DO holds authoritative state, caches are derived// Use Workers KV for true caching3. Long-Running Requests
Section titled “3. Long-Running Requests”Wrong:
async processLargeDataset(data: Data[]): Promise<void> { for (const item of data) { await this.process(item); // 30s timeout will hit }}Right:
async processLargeDataset(data: Data[]): Promise<void> { // Store work, schedule alarm await this.storage.put('pending_work', data); await this.storage.setAlarm(Date.now());}
async alarm(): Promise<void> { const work = await this.storage.get('pending_work'); const batch = work.splice(0, 100); // Process batch... if (work.length > 0) { await this.storage.put('pending_work', work); await this.storage.setAlarm(Date.now() + 10); }}z0 DO Architecture
Section titled “z0 DO Architecture”DO Classes
Section titled “DO Classes”| Class | ID Scheme | Example | Purpose |
|---|---|---|---|
AccountDO | account_{entity_id} | account_acct_123 | Account ledger, budget state |
AssetDO | asset_{entity_id} | asset_ast_456 | Asset ledger, cap state |
ContactDO | contact_{entity_id} | contact_con_789 | Contact ledger, canonical identity |
DealDO | deal_{entity_id} | deal_deal_012 | Deal ledger, outcome tracking |
ConfigDO | config_{config_id} | config_cfg_345 | Config versioning (shared configs) |
ID Convention: Entity IDs include a type prefix (e.g., acct_123, ast_456). DO IDs prepend the DO namespace to the full entity ID, yielding account_acct_123. This ensures globally unique DO identifiers while preserving entity ID semantics.
DO Lifecycle
Section titled “DO Lifecycle”1. First request to entity_id → DO created, SQLite initialized
2. Subsequent requests → Routed to existing DO
3. Idle period (no requests) → DO may be evicted from memory → SQLite persists
4. Next request after eviction → DO rehydrated from SQLite → Continues where it left off
5. Alarm fires → DO woken if evicted → Alarm handler runsSummary
Section titled “Summary”| Concept | z0 Usage |
|---|---|
| What DOs are | Single-threaded, durable, edge-located compute with SQLite |
| Per-entity ledgers | Each Entity with a ledger gets its own DO with Facts, state, configs |
| Single-writer | Inherent serialization eliminates race conditions |
| SQLite storage | Hot data in DO, replicated to D1 for reporting |
| Alarms | Background replication, reconciliation, maintenance |
| Constraints | 30s requests, 10GB storage, one alarm at a time |
DOs implement the Fact ledger (source of truth, not derived). The DO SQLite contains the authoritative Entity, Fact, and Config records. Cached state within DOs (BudgetState, CapState, PrepaidBalance) IS derived and disposable, rebuilt from the ledger per Principle 7. D1 is also derived—a queryable replica for cross-entity reporting.