Skip to content

Durable Objects

Stateful compute at the edge. The foundation for z0 per-entity ledgers.

Prerequisites: PRINCIPLES.md, PRIMITIVES.md


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.

PropertyImplication for z0
Single-threadedNo race conditions. Config versioning is serialized per config_id.
Globally uniqueOne DO per entity_id. Single source of truth.
Durable storageSQLite per DO. Facts survive restarts.
Edge-locatedLow latency reads. RTB hot path can stay fast.
Alarm supportBackground 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.


Each Entity with a ledger (account, asset, contact, deal) gets its own DO containing:

  1. The Entity record — current state
  2. Facts ledger — append-only log of Facts involving this Entity
  3. Cached state — BudgetState, CapState, etc.
  4. 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.

AlternativeProblem
Single global DOSerializes all writes. Doesn’t scale.
Per-tenant DOHot tenants become bottlenecks.
Per-request DOLoses single-writer benefit. Race conditions return.
Per-entity DONatural sharding. Writes scale with entities.

Result: Write throughput scales linearly with entity count. Hot entities (high-volume assets) get dedicated compute.


Each DO has a private SQLite database. This is where Facts live before replication to D1.

-- Entity record
CREATE 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 state
CREATE 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;
ResourceLimitz0 Implication
SQLite database size10 GBPlenty for hot data. Archive old Facts to R2.
Row sizeNo hard limitKeep Facts focused. Large payloads go to R2.
Concurrent connections1 (single-threaded)By design. No connection pooling needed.

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.


The single-writer pattern is not something we implement—it’s what DOs give us inherently.

Request A: append Fact to account_123
Request 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.
RequirementHow DOs Help
Fact orderingFacts append in arrival order. No conflicts.
Config versioningVersion increment is atomic. No lost updates.
Budget enforcementRead-check-write is atomic. No overspend.
Cached state updatesUpdate after Fact append is atomic.
// Inside DO class
async 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();
}

DOs support alarms—scheduled wake-ups without external infrastructure.

Alarm TypePurposeSchedule
ReplicationPush Facts to D1After each write (debounced)
ReconciliationVerify cached state matches FactsEvery 5 minutes
ExpirationArchive old Facts to R2Daily
Health checkEnsure DO is responsiveEvery minute
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
}
}
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
}

Use CaseWhy
Per-entity ledgersSingle-writer guarantees ordering
Config versioningAtomic read-modify-write
Budget/cap enforcementConsistent read-check-write
Cached state with reconciliationState lives with its source Facts
RTB hot path readsLow-latency edge access
Use CaseUse InsteadWhy
Cross-tenant reportingD1DOs are per-entity
Historical analyticsAnalytics EngineDOs optimize for recent data
Large file storageR2SQLite not for blobs
High-fanout broadcastsQueuesDOs don’t parallelize
Stateless computeWorkersDOs have overhead
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 precedence (from PRIMITIVES.md) is: asset > campaign > account. This section explains how DOs resolve the correct Config at runtime.

Config resolution uses a DO-local-first approach with stub calls for parent scopes:

  1. Check entity’s own DO SQLite for a Config of the requested type
  2. If not found, call the parent entity’s DO (campaign, then account) via stub
  3. Cache the resolved Config in the entity’s DO for hot path performance
// Inside AssetDO
async 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}`);
}
ApproachProsConsVerdict
Query D1Single query, simpleD1 is eventually consistent, adds latencyNot for hot path
Replicate all Configs to every DOFast readsStorage bloat, consistency complexityNot scalable
Stub calls to parent DOsAuthoritative, consistentMultiple round tripsPreferred

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

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.

When a Config is updated at a parent scope:

  1. Immediate: Parent DO has new version
  2. On next resolution: Child DOs will fetch new version (cache miss or TTL expiry)
  3. 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)

LimitValueMitigation
Request duration30 secondsBreak long operations into alarms
Concurrent requestsSerialized (1 at a time)By design. Don’t fight it.
Requests per second per DO~1000Shard hot entities if needed
Subrequest count1000 per requestBatch operations
LimitValueMitigation
SQLite size10 GBArchive old Facts to R2
Transactional writes128 MBBatch large inserts
Storage classStandard onlyDesign for durability, not cold storage
LimitValueNote
Minimum interval0 (immediate)Can schedule alarm for now
Maximum future30 daysReschedule for longer
Alarms per DO1 at a timeNew alarm overwrites old
LimitValueNote
DO classes per account100Plan class hierarchy carefully
Unique DOsUnlimitedScale with entities
Geographic distributionAutomaticDOs migrate to callers

Wrong:

// One DO to rule them all
class GlobalRouter {
async route(request: Request): Promise<Response> {
// All routing goes through one DO
// Becomes bottleneck at ~1000 RPS
}
}

Right:

// Route to entity-specific DO
const entityDO = env.ENTITY_DO.get(entityId);
return entityDO.fetch(request);

Wrong:

// Using DO as a cache layer
async 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 caching

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);
}
}

ClassID SchemeExamplePurpose
AccountDOaccount_{entity_id}account_acct_123Account ledger, budget state
AssetDOasset_{entity_id}asset_ast_456Asset ledger, cap state
ContactDOcontact_{entity_id}contact_con_789Contact ledger, canonical identity
DealDOdeal_{entity_id}deal_deal_012Deal ledger, outcome tracking
ConfigDOconfig_{config_id}config_cfg_345Config 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.

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 runs

Conceptz0 Usage
What DOs areSingle-threaded, durable, edge-located compute with SQLite
Per-entity ledgersEach Entity with a ledger gets its own DO with Facts, state, configs
Single-writerInherent serialization eliminates race conditions
SQLite storageHot data in DO, replicated to D1 for reporting
AlarmsBackground replication, reconciliation, maintenance
Constraints30s 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.