SOLENOID.SYSTEMS [...]
SYS ENG PHI PRICING
API
├─ CATCH ├─ GATE ├─ KEY ├─ LATCH ├─ METER ├─ PULSE ├─ RELAY └─ WITNESS

Durable Objects in Production: Lessons from Building Real Infrastructure

#cloudflare #durable-objects #architecture #infrastructure

Cloudflare Durable Objects power three of our four products. Relay uses them for webhook scheduling. Witness uses them for hash chains. Meter uses them for credit tracking. Only Gate uses KV alone.

After a year in production, here’s what we’ve learned.

The Mental Model That Clicks

Durable Objects are single-threaded actors with durable storage. Each object has a unique ID, lives in one location, and processes requests sequentially.

Think of them as “one microservice instance per entity.” One DO per webhook. One DO per user’s hash chain. One DO per credit meter. They’re not a database—they’re stateful compute.

The power comes from co-locating state and logic. The webhook timer and the webhook delivery code live together. No coordination, no race conditions, no distributed transactions.

What Actually Works Well

Sequential processing eliminates races. Meter tracks credit balances. Two concurrent decrements can’t oversell because the DO processes them one at a time. We didn’t write locking code. The runtime guarantees it.

// This is safe without explicit locking
async decrement(amount: number): Promise<boolean> {
  const balance = await this.ctx.storage.get<number>('balance') ?? 0
  if (balance < amount) return false
  await this.ctx.storage.put('balance', balance - amount)
  return true
}

Alarms are reliable timers. Relay schedules webhooks by setting alarms. The DO wakes up, delivers the webhook, and cleans up. We’ve processed millions of scheduled deliveries. Alarms fire on time, survive restarts, and don’t drift.

async schedule(delayMs: number) {
  await this.ctx.storage.setAlarm(Date.now() + delayMs)
}

async alarm() {
  const webhook = await this.ctx.storage.get('webhook')
  await this.deliver(webhook)
  await this.ctx.storage.deleteAll()
}

Automatic scaling is real. We don’t provision capacity. Cloudflare creates DOs as needed, garbage collects idle ones, and handles the infrastructure. During a traffic spike, we create more DOs. During quiet periods, memory usage drops. We pay for what we use.

Global distribution just works. A DO lives in one location, but that location is chosen to be close to where it’s accessed. Witness chains created in Europe stay in Europe. Low latency without configuration.

What Bit Us

Cold starts matter. A DO that hasn’t been accessed recently needs to spin up. First request is slower while the DO initializes. Subsequent requests are fast. For Meter’s hot-path balance checks, we keep DOs warm with synthetic reads.

Storage operations aren’t free. Each storage.put() and storage.get() has latency. Batch operations when possible:

// Slower: multiple round trips
const a = await this.ctx.storage.get('a')
const b = await this.ctx.storage.get('b')

// Faster: single round trip
const values = await this.ctx.storage.get(['a', 'b'])

Large objects need chunking. Storage has a 128KB value limit. Witness chains that grow large need pagination. We chunk chain history into segments and store metadata separately.

No cross-DO transactions. If you need to update two DOs atomically, you can’t. Design around this. Relay doesn’t need it—each webhook is independent. If your domain requires cross-entity transactions, DOs might not be the right fit.

Hibernation changes the model. ctx.waitUntil() works differently when using Hibernation APIs. We had to restructure some cleanup logic when we enabled hibernation for cost savings.

When to Use Durable Objects

Good fits:

  • Per-user state that needs consistency (credit meters)
  • Scheduled tasks with reliable timers (webhook scheduling)
  • Sequential append-only structures (audit chains)
  • Anything where “one actor per entity” maps to your domain

Poor fits:

  • Complex queries across many entities (use a database)
  • High-throughput shared state (use KV or D1)
  • Transactions spanning multiple entities
  • Large datasets per entity (storage limits apply)

Gate doesn’t use DOs because flags are read-heavy, write-rare, and don’t need per-flag isolation. KV is simpler and cheaper for that pattern.

Cost Reality

DOs bill for duration, storage, and requests. The per-request cost is higher than Workers alone. For our use cases, the tradeoff is worth it:

  • No database to operate
  • No queue infrastructure
  • Built-in distributed state
  • Zero DevOps overhead

Compare to running Redis + Postgres + job workers for equivalent functionality. DOs are cheaper in total cost of ownership, not just dollar cost.

Production Patterns We Use

ID design matters. DO IDs should be stable and derivable. We use {product}:{account_id}:{entity_id} patterns. Meter: meter:acct_123:user_456. Witness: witness:acct_123:chain_main.

Graceful degradation. If a DO is slow or erroring, we fail fast rather than hang. Set aggressive timeouts on DO fetches.

Observability through headers. We return X-DO-Id and X-DO-Location headers in responses. When debugging, we know exactly which DO handled the request.

Explicit cleanup. When an entity is deleted (user closes account), explicitly delete DO storage. Don’t rely on garbage collection timing.

Should You Use Them?

If your problem maps to “many independent entities each needing consistent state and compute,” Durable Objects are compelling. The programming model is simpler than distributed databases. The operational model is simpler than self-hosted alternatives.

If your problem is “aggregate queries across entities” or “shared mutable state at high throughput,” look elsewhere. D1, KV, or traditional databases are better fits.

We built Solenoid on DOs because our products are entity-centric. Each webhook is independent. Each user’s credits are independent. Each audit chain is independent. The model fits.

Your model might be different. Choose infrastructure that matches your domain.