Skip to main content

Chapter 6: Durable Objects: Stateful Compute at the Edge

How do I build applications that need coordination, real-time updates, or consistent state?


Few cloud primitives are as distinctive as Durable Objects. AWS has no equivalent; Azure's closest analogue requires multiple services stitched together, and Google Cloud offers nothing comparable. Durable Objects represent Cloudflare's most significant contribution to cloud computing, and they demand a fundamentally different mental model from other platforms.

The Mental Model Shift

Durable Objects aren't servers you provision; they're database rows that run code. Think "one object per user" or "one object per document," not "how many objects do I need for capacity." This shift from vertical to horizontal thinking determines success with Durable Objects.

Durable Objects launched in 2020 to solve a problem Workers couldn't: coordinating state across distributed requests. Five years of production use across thousands of applications have proven the model. The SQLite storage backend, added in 2024, brought full relational capabilities to what was originally a key-value system.

A Durable Object is a Worker with a globally-unique identity and private, strongly-consistent storage. That simple description has profound implications: you can create an object representing a single user, document, game session, or chat room, and that object coordinates all operations on that entity from anywhere in the world with strong consistency guarantees, without you managing any infrastructure.

If you find yourself counting Durable Objects to minimise cost, you're approaching them wrong. Shift your thinking from "how do I minimise instances" to "one object per logical entity." This shift produces cleaner architecture and better performance.

The conceptual model

Understanding Durable Objects requires abandoning assumptions other platforms instil.

Single-threaded, globally-unique actors

Each Durable Object is a single-threaded JavaScript execution context with a unique identity. Regardless of where a request originates, requests to the same Durable Object route to the same instance, which processes them sequentially, eliminating race conditions by design.

This is the actor model at global scale. Carl Hewitt conceived the actor model at MIT in 1973, and it evolved into production systems like Erlang (powering much telecommunications), Microsoft Orleans (powering Xbox Live), and Akka (widely used in financial services). The core insight is simple: instead of shared memory protected by locks, each actor owns its state exclusively and communicates only through messages. What distinguishes Durable Objects is global uniqueness backed by automatic durable storage, capabilities other actor systems require significant infrastructure to achieve.

More precisely, Durable Objects are active objects: they encapsulate both state and a thread of control. Passive objects like database rows hold state but rely on external threads to operate on them. When you call a method on a passive object, your thread enters the object; when you send a message to an active object, the object's own thread processes it. Active objects enforce invariants through their execution model, not just careful coding. A Durable Object doesn't need mutex guards because only its thread touches its state.

The uniqueness is global. If you create a Durable Object named "user-123", every request targeting "user-123" reaches the same instance, whether originating in Tokyo, Toronto, or Tallinn. Routing is automatic. You address the object by name; the platform handles the rest.

Think database rows, not servers

Cloudflare engineers describe Durable Objects as "a row in our database." Creating millions is normal and cheap, because each object is lightweight, created on demand, and automatically garbage-collected when no longer needed. Rather than thinking of them as servers you provision, think of them as database rows that run code. A chat application creates one per conversation; a multiplayer game creates one per session, one per player, and one per match; a collaborative editor creates one per document. Your domain model determines the number of objects, not capacity planning.

Coordination primitive first, storage second

Durable Objects provide both coordination and storage, but coordination is the primary value because single-threaded execution eliminates the distributed locking, consensus protocols, and race condition handling that plague other architectures.

Consider rate limiting. Traditional architectures use Redis with atomic increments, carefully handling race conditions and distributed counter semantics. With Durable Objects, you create one object per rate-limited resource, and single-threaded execution ensures atomic increment without special handling: no Lua scripts, no WATCH/MULTI/EXEC, no optimistic locking.

Consider document collaboration. Traditional architectures require operational transformation or CRDTs (conflict-free replicated data types that let concurrent edits merge automatically), version vectors, and conflict resolution. With Durable Objects, all edits to a document route to one object, which serialises them naturally.

Each object can store up to 10 GB in SQLite, but coordination guarantees often matter more than storage capacity.

When to use Durable Objects

Durable Objects aren't the only stateful option on Cloudflare. Knowing when to reach for them versus D1 or KV prevents over-engineering and unnecessary cost.

The decision framework

Ask one question: do concurrent requests to this entity need to see each other's effects immediately? If yes, use Durable Objects. If requests are independent and eventual consistency is acceptable, D1 or KV is simpler and cheaper.

Use KV for read-heavy data where eventual consistency is acceptable (updates may take 60 seconds to propagate) and access patterns are simple key-value lookups: configuration, feature flags, cached API responses.

Use D1 when you need relational queries, consistency within a single database matters, but you don't need coordination across concurrent requests: user profiles, product catalogues, content that updates occasionally.

Use Durable Objects when multiple requests must coordinate on the same entity, you need strong consistency with immediate read-after-write guarantees, you're building real-time features, or you need WebSocket connections with shared state.

A user profile tolerating stale reads? D1. A shopping cart where concurrent browser tabs must see consistent state? Durable Objects. A rate limiter tracking requests across a time window? Durable Objects. A read-heavy product catalogue? D1 with KV caching.

The cost reality

Durable Objects incur costs for requests, compute duration, and storage. For high-volume, read-heavy workloads without coordination needs, D1 with KV caching is typically cheaper. But for coordination-heavy workloads, Durable Objects are often the only viable option. You're not choosing between cheaper alternatives; you're choosing between Durable Objects and building your own coordination layer from multiple services.

ScenarioCloudflare (DO)AWS (API GW + Lambda + DynamoDB)
10,000 DAU, 100 messages each~$4–5/month~$20–40/month
100,000 DAU, 100 messages each~$45/month~$200–400/month
1M DAU, 100 messages each~$450/month~$2,000–4,000/month

These estimates assume a chat-style workload with mixed reads and writes. AWS figures include API Gateway WebSocket charges, Lambda invocations, and DynamoDB on-demand pricing with strong consistency reads. Your costs will vary, but the ratio (Cloudflare at roughly 10-20% of AWS equivalent) holds across most coordination-heavy workloads. The gap widens when you factor in operational overhead: managing connection state across Lambda invocations, handling DynamoDB's eventual consistency, and configuring API Gateway's connection limits.

If you're using Redis for coordination

Many readers considering Durable Objects currently use Redis for rate limiting, distributed locks, or real-time state. The mental model shift is significant: Redis is a shared cache you coordinate through; a Durable Object is a single-threaded actor that coordinates for you. With Redis, you write Lua scripts or use WATCH/MULTI/EXEC to achieve atomicity. With Durable Objects, atomicity is inherent: single-threaded execution means your code runs without interleaving.

Redis requires you to manage connection pools, handle failover, and reason about consistency during network partitions. Durable Objects handle all this invisibly. But Redis offers portability across any cloud or on-premises environment, a massive ecosystem of client libraries and tooling, rich data structures like sorted sets and streams, and horizontal scaling through clustering without application-level sharding.

The trade-off is portability versus simplicity. If you might run on AWS, Azure, or on-premises, Redis keeps your options open. If you're committed to Cloudflare, Durable Objects eliminate operational complexity Redis requires. But there's no lift-and-shift path from Durable Objects to anything else.

Communicating with Durable Objects

Before examining storage and durability guarantees, understand how Workers talk to Durable Objects. The communication model shapes how you design your object's interface.

RPC: the modern approach

With compatibility date 2024-04-03 or later, define typed methods directly on your Durable Object class and call them from your Worker:

src/durable-object.ts
// In the Durable Object
async getProfile(): Promise<Profile | null> { /* ... */ }
async updateProfile(updates: Partial<Profile>): Promise<void> { /* ... */ }
src/worker.ts
// In the Worker
const stub = env.USER_PROFILE.get(id);
const profile = await stub.getProfile();
await stub.updateProfile({ name: "New Name" });

RPC provides type safety, cleaner code, and better error messages than parsing requests manually in a fetch() handler.

warning

Always await RPC calls. Unawaited calls create dangling promises that swallow errors silently. Your code appears to work while failing invisibly.

One quirk: Durable Objects don't inherently know their own ID. If methods need it for logging or cross-references, pass it explicitly during initialisation or include it in each method call.

When fetch() still makes sense

The older pattern routes HTTP requests through a fetch() handler on the Durable Object. This remains appropriate in two scenarios.

First, when proxying HTTP requests where request/response semantics matter. If your Durable Object mediates access to an external API and clients expect standard HTTP responses with specific headers, status codes, and streaming bodies, fetch() preserves those semantics naturally. RPC would require reconstructing them.

Second, when migrating an existing codebase incrementally. A production system built on fetch() routing can migrate method by method. New functionality uses RPC; legacy paths continue through fetch() until converted.

For new code without these constraints, prefer RPC. Type safety catches errors at development time, and the calling code reads like what it is: method invocations on an object.

Storage backend

Each Durable Object contains a private SQLite database, accessed through the sql property on the storage API. You have full SQL capabilities: queries, joins, indexes, transactions, FTS5 full-text search, and JSON functions.

SQLite storage in Durable Objects
this.ctx.storage.sql.exec(
"INSERT INTO moves (player_id, move_data) VALUES (?, ?)",
playerId, JSON.stringify(moveData)
);

const leaders = this.ctx.storage.sql.exec(
"SELECT name, score FROM players ORDER BY score DESC LIMIT 10"
).toArray();

A legacy key-value API exists for compatibility but is backed by SQLite internally; for new code, use SQL directly, as it's more powerful and no slower.

Storage limits and their implications

Each Durable Object can store up to 10 GB in its SQLite database, with individual rows limited to 2 MB. These limits rarely constrain correct designs. If you're approaching 10 GB in a single object, reconsider your sharding strategy. The 2 MB row limit matters for document storage; chunk large documents or store them in R2 with references in SQLite.

Durable Object Storage Limits Reference
ResourceLimitNotes
SQLite database size10 GBPer Durable Object
Row size2 MBUse R2 for larger documents
Key-value key size2 KBLegacy API
Key-value value size128 KBLegacy API
Concurrent connections32,768WebSocket connections per object
Storage Eviction Reality

After roughly 10 seconds of idleness, Durable Objects may be evicted from memory. State not persisted to SQLite is lost. The constructor runs again on the next request. This isn't a bug; it's fundamental to the economic model. Make memory-versus-SQLite decisions explicit for every piece of state.

Memory, SQLite, and the eviction reality

After roughly 10 seconds of idleness, a Durable Object may be evicted from memory. The next request recreates it, running the constructor again. State not persisted to SQLite is lost.

Use SQLite for anything that must survive eviction; use in-memory state only for true caches where loss is acceptable. But "acceptable loss" deserves nuance. A hot counter read thousands of times per second but written once per second benefits from in-memory caching: read from memory, write-through to SQLite, accept that a crash loses at most one second of increments. Session data expensive to reconstruct belongs in SQLite. Computed values derivable from SQLite state can live in memory as a performance optimisation.

The pattern that causes trouble is implicit reliance on memory. Storing state in class properties without consciously deciding whether it should survive eviction creates bugs that appear in production (where objects go idle between requests) but not in development (where objects stay warm during active testing).

Point-in-time recovery

SQLite-backed Durable Objects support point-in-time recovery, allowing restoration to any moment in the last 30 days. This matters most for disaster recovery and debugging: understanding what state an object held at a specific moment, or restoring an object corrupted by a bug. The mechanism uses bookmark strings you obtain for the current moment or any past timestamp, then schedule restoration on the object's next restart. For most applications, this is insurance you configure once and forget until needed.

How durability actually works: output gating

Output gating provides strong consistency guarantees without sacrificing performance. Understanding it is essential for reasoning about Durable Objects correctly and structuring your code.

The Critical Guarantee

If you received a response from a Durable Object, the data is durable. You cannot observe uncommitted state from outside the object. This is stronger consistency than most databases provide by default, and it's automatic.

The problem output gating solves

When you write to a Durable Object's storage, the write completes in microseconds, accepted into an in-memory buffer and acknowledged immediately. But durability requires replication to multiple data centres, which takes longer. This creates a window where your Durable Object has accepted a write that could be lost if the machine fails before replication completes.

The naive solution: wait for replication before acknowledging the write. But this adds latency to every operation. A counter increment taking microseconds locally would take milliseconds waiting for replication. For high-throughput applications, this latency compounds.

The mechanism

When your code executes a storage operation, data writes to the Durable Object's in-memory SQLite instance immediately, completing in microseconds. Your code continues executing while the runtime begins replicating the write to five geographically distributed storage nodes. The key insight: any external side effect (a response to the caller, an outbound fetch() request, a WebSocket message) enters a pending state, held by the output gate. The gate remains closed until durability is confirmed, typically 2-10 milliseconds. Once confirmed, pending responses and outbound requests proceed.

If replication fails, the pending response is replaced with an error and the Durable Object restarts. The client never sees a "success" for data that wasn't persisted.

Why this matters for architects

Output gating eliminates an entire category of distributed systems bugs. You don't need read-after-write consistency handling; it's guaranteed. Stale reads cannot occur. You don't need retry logic to confirm writes; the response confirms durability.

This is stronger than most databases provide. PostgreSQL with synchronous replication comes close but requires careful configuration and explicit transaction management. DynamoDB's strong consistency mode provides read-your-writes within a region but not linearisability. Durable Objects provide linearisability by default, with no configuration.

The trade-off is latency. Write operations that seem instant (microseconds in memory) include replication time before external communication proceeds. Typically single-digit milliseconds, imperceptible to users but material for high-throughput batch operations. If your P99 latency budget is under 50ms and you're making multiple sequential round-trips to Durable Objects, cumulative gating latency becomes significant. For most applications, it's invisible.

Input gates: automatic race condition prevention

Complementing output gates, input gates protect against a subtler problem. While synchronous JavaScript executes, new events (incoming requests, fetch responses) are blocked from entering the Durable Object. Awaiting opens the gate, allowing new events to interleave.

This prevents a common distributed systems bug. Without input gates, a read-modify-write sequence could interleave badly: read value, another request reads same value, first request writes, second request writes, second write clobbers first. Input gates make intuitive code work correctly by ensuring your synchronous code completes before new events arrive.

The practical implication: don't await unnecessarily during critical sections. Once you await, other events can interleave. Complete your read-modify-write logic synchronously, then await the storage operation. The storage write itself is protected by output gating; the computation leading to that write is protected by input gating.

Write coalescing and its constraints

Multiple storage operations without intervening await statements on non-storage operations coalesce into a single atomic transaction:

Atomic write coalescing
// These three writes commit atomically
this.sql.exec("UPDATE players SET name = ? WHERE id = ?", name, id);
this.sql.exec("UPDATE players SET score = ? WHERE id = ?", score, id);
this.sql.exec("INSERT INTO audit_log (player_id, action) VALUES (?, 'update')", id);

Write coalescing lets you issue multiple SQL statements knowing they'll commit atomically without explicit transaction syntax. But any await on non-storage operations forces preceding writes to commit. Complete all storage operations before making external calls. Interleaving storage operations with external I/O breaks atomicity without any explicit indication in the code.

Placement and routing

Durable Objects are placed geographically based on where they're first accessed. A simple algorithm that works well, with caveats worth understanding.

How placement works

When you first access a Durable Object, Cloudflare creates it in the data centre handling that request. A user in Tokyo first accessing "game-session-123" creates that object in or near Tokyo. This works because correct sharding aligns objects with their users. A per-user Durable Object is accessed primarily by that user, so placing it near their first request places it near most subsequent requests.

When your Worker first requests an object that doesn't exist, the platform resolves the ID (deterministically for idFromName()), selects a data centre for placement (by default, wherever the request arrived), instantiates your class, and initialises its SQLite database. If your constructor uses blockConcurrencyWhile(), requests queue until it completes. Subsequent requests skip placement and instantiation if the object remains in memory; after idle timeout, the next request re-runs the constructor but not placement. The object stays where it was created.

Geographic latency realities

Durable Objects infrastructure is denser in North America and Western Europe than elsewhere. Same-region access typically adds 1-5ms for a method call. Cross-continent access adds 50-150ms, dominated by network latency. Storage operations take 2-10ms for a write including output gating; reads from cache complete in microseconds. Hibernation wake adds 50-100ms when a hibernating object receives its first message.

Users in North America and Western Europe typically experience single-digit millisecond latency to their objects. Users in Southeast Asia, South America, or Africa may experience higher latency, particularly if their object was first created by a user in a different region. A user in São Paulo accessing an object first created in London routes there for every request, adding 150-200ms to every operation.

This is improving as Cloudflare deploys infrastructure to more regions, but verify for your user geography before committing to a Durable Object-heavy architecture.

Location hints and jurisdictional restrictions

When you know the optimal location before first access, provide a location hint when obtaining the ID. Location hints are suggestions, not guarantees (Cloudflare may place objects elsewhere based on capacity), but they improve placement when pre-creating objects for users or placing objects near known backend infrastructure.

For compliance requirements, restrict Durable Objects to specific jurisdictions in your configuration. The object only runs in data centres within that jurisdiction and storage only persists there, but the object remains globally accessible. Requests from outside the jurisdiction route to it; processing happens within the jurisdiction. This enables GDPR compliance without sacrificing global accessibility.

Real-time capabilities: the economics of long-lived connections

Durable Objects excel at real-time features, but not because WebSocket handling is novel. The innovation is economic: hibernation makes long-lived connections viable at scale.

The cost problem with websockets

Traditional architectures charge for compute time while connections are open. A chat room with 100 idle users still consumes server resources, still incurs costs. This creates pressure to disconnect idle users, implement complex connection pooling, or accept high costs for real-time features.

Hibernation changes this calculus. When a Durable Object has no active work, just idle WebSocket connections, it hibernates. Connections remain open, but the object stops consuming compute resources. When a message arrives, the object wakes, processes the message, and can return to hibernation. Long-lived connections become a feature, not a cost centre.

When hibernation matters

The economics become significant above roughly 10,000 concurrent idle connections. Below that threshold, the cost difference is negligible, just a few dollars per month. Above it, hibernation can reduce costs by an order of magnitude.

If connections are short-lived (seconds rather than hours), hibernation rarely engages. If objects receive constant traffic with no idle periods, hibernation never triggers. The sweet spot: applications with many connections mostly idle but occasionally active. Collaborative documents where users have files open but aren't always editing, chat applications where conversations go quiet for hours, dashboards updating periodically rather than continuously.

What hibernation requires

Hibernation works when the Durable Object acts as a WebSocket server, accepting connections from clients. Outgoing WebSocket connections to external services cannot hibernate; the object must remain active to maintain them. If your Durable Object needs connections to external services, it cannot benefit from hibernation's cost savings.

Enable hibernation by using ctx.acceptWebSocket() rather than manually managing WebSocket pairs, and by implementing webSocketMessage() and webSocketClose() handlers rather than attaching event listeners.

Connection metadata survives hibernation through serializeAttachment(). Store user IDs, tokens, and essential metadata (up to 2 KB) with each connection. For richer per-connection state, store a key in the attachment and look up full state in SQLite when the object wakes.

Deployment resets connections

When you deploy a new version of your Worker, every Durable Object restarts, closing all WebSocket connections. Design clients to reconnect automatically and rehydrate state from the Durable Object. This isn't a bug to work around but a reality to design for. Your reconnection logic will be exercised regularly.

WebSocket Deployment Impact

Every deployment disconnects all WebSocket connections across all Durable Objects. Client reconnection logic isn't optional; it's required for production systems. Test reconnection flows as thoroughly as initial connection flows.

The one pattern that matters

Every Durable Objects architecture reduces to one pattern: one object per logical entity that needs coordination. Rate limiting, counters, leader election, chat rooms, collaborative documents: not separate patterns but applications of the same principle.

Routing to the right object

Your Worker routes requests to objects based on entity identity:

Routing requests to Durable Objects
const userId = getUserIdFromRequest(request);
const id = env.USER.idFromName(userId);
const stub = env.USER.get(id);
return stub.fetch(request);

All operations on a user route to one object. All operations on a document route to one object. All operations on a game session route to one object. The pattern is always the same; only the entity changes.

This aligns the coordination boundary with the data boundary. You don't need distributed transactions because all related operations happen in one place. You don't need consensus protocols because one object makes all decisions for its entity.

Applied coordination: rate limiting and leader election

Rate limiting becomes trivial: one object per rate-limited resource, increment a counter, check against a limit. Single-threaded execution guarantees atomicity. No Redis, no Lua scripts, no race conditions. A few lines of SQL. The insight: coordination, not storage, is what makes rate limiting hard.

Leader election follows the same principle. A Durable Object holds a lease. Store leader_id and expires_at in SQLite; candidates call a method that checks expiry and atomically acquires or renews the lease. The one that succeeds is leader until its lease expires. No distributed consensus protocol. No ZooKeeper. Single-threaded execution makes check-and-acquire atomic by construction.

Control plane and data plane separation

For systems managing many resources (game sessions, chat rooms, tenant workspaces), separate coordination concerns from data concerns using the control plane / data plane pattern.

The control plane Durable Object handles administrative operations: creating resources, listing resources per user, tracking resource metadata. The data plane comprises individual Durable Objects for each resource, handling actual operations on that resource's state and logic.

The critical insight is that data plane operations bypass the control plane entirely. Once you know which game session to join, requests route directly to that session's Durable Object. The control plane isn't in the hot path. This prevents a single coordination point from becoming a bottleneck while maintaining the ability to manage and discover resources.

Control plane / data plane separation
// Control plane: manages resource registry
class WorkspaceRegistry extends DurableObject {
async createProject(userId: string, name: string): Promise<string> {
const projectId = crypto.randomUUID();
await this.ctx.storage.sql.exec(
"INSERT INTO projects (id, user_id, name) VALUES (?, ?, ?)",
projectId, userId, name
);
return projectId;
}

async listProjects(userId: string): Promise<Project[]> {
return this.ctx.storage.sql.exec(
"SELECT * FROM projects WHERE user_id = ?", userId
).toArray();
}
}

// Data plane: handles actual project operations (separate DO per project)
class Project extends DurableObject {
async addDocument(doc: Document): Promise<void> { /* ... */ }
async updateSettings(settings: Settings): Promise<void> { /* ... */ }
}

The pattern scales horizontally. The control plane tracks thousands of resources; each operates independently. Single-threaded guarantees apply within each object, not across them.

When to split into multiple objects

Sometimes state that seems related should live in separate Durable Objects.

Split when entities have different access patterns. A user's profile (updated occasionally) and their real-time cursor position (updated constantly) probably belong in separate objects. The profile tolerates higher latency and benefits from simpler access patterns; the cursor position needs minimal latency and generates high write volume.

Split when entities have different consistency requirements. If some data needs strong consistency while related data tolerates eventual consistency, separating them lets you use the appropriate primitive for each.

Split when combining would create a throughput bottleneck. A single Durable Object handles roughly 1,000 requests per second, plenty for one user, one document, or one game session, but not for aggregate traffic. If you're routing all requests to one object, you've misunderstood the model.

The 1,000 RPS Ceiling

A single Durable Object handles approximately 1,000 requests per second. This is plenty for per-user or per-entity coordination, but routing aggregate traffic to one object creates a bottleneck that limits scalability.

Combine when operations need atomicity across the data. If updating A and B must either both succeed or both fail, they belong in the same object. Splitting them means accepting eventual consistency or building your own coordination, defeating the purpose of using Durable Objects.

Throughput limits and what to do about them

The 1,000 requests per second ceiling reflects conservative concurrency. Single-threaded execution eliminates conflicts by eliminating concurrency entirely. For coordination-heavy workloads where conflicts would be frequent anyway, this trade-off favours simplicity. But what if you genuinely need more throughput for a single entity?

First, verify you actually do. A single user won't generate 1,000 requests per second. A document with 100 concurrent editors, each making one edit per second, generates 100 requests per second, well within limits. Scenarios exceeding the limit are rare.

If you've verified the need, your options: client-side batching (aggregate multiple operations into single requests), read replicas via KV for read-heavy patterns (cache frequently-read data, accept 60-second staleness), or sharding within the entity (split a high-traffic counter into 10 shard objects, aggregate reads). Each adds complexity. Often, hitting the throughput limit indicates a design that doesn't fit the model. Reconsider whether Durable Objects are right for that entity.

Hierarchical coordination

For complex systems, use parent-child relationships. A lobby object tracks active games (IDs, status, player counts) while individual game objects handle each game's state and logic. The parent coordinates and indexes; children hold the data. This scales horizontally: the lobby tracks thousands of games, each operates independently, and single-threaded guarantees apply within each object, not across them.

Lifecycle and scheduled work

Understanding when Durable Objects wake and sleep prevents subtle bugs and enables efficient designs.

Idle timeout and constructor semantics

A Durable Object with no pending operations enters an idle state. After roughly 10 seconds, it may be evicted from memory. The next request recreates it, running the constructor again.

This means three things. First, don't rely on in-memory state persisting; always store important data to SQLite. Second, the constructor runs on every wake, so use it to initialise state, load from storage, and run migrations. Third, there is no destructor. You cannot run cleanup code on eviction because it might never happen, or might happen without warning.

Use blockConcurrencyWhile() in the constructor to ensure initialisation completes before handling requests. This blocks all requests until the promise resolves, appropriate for migrations and loading critical state, but a throughput bottleneck if overused.

Alarms for scheduled wake-up

Alarms wake a Durable Object at a scheduled time, even if no external requests arrive. Set an alarm, handle it in alarm(), and reschedule if work is recurring. Each object has one alarm slot; setting a new alarm overwrites the previous. Alarms provide at-least-once execution with automatic retry on failure, so design handlers to be idempotent. Timing is typically precise to milliseconds, though delays up to one minute are possible under load.

Use alarms when the schedule is per-object: each user's subscription renews on a different date, each document's cleanup runs 24 hours after last edit. Use Cron Triggers when the schedule is global: daily cleanup at midnight, hourly aggregation on the hour. Use Queues when work is triggered by events rather than time.

Testing Durable Objects

Testing Durable Objects presents challenges Chapter 5 addresses in detail, but the core difficulty deserves mention. The single-threaded, globally-unique semantics that make Durable Objects powerful also make them hard to test in isolation.

Unit tests can mock the storage API and verify your object's logic, but can't test coordination guarantees. Those emerge from the runtime, not your code. Integration tests with remote bindings to a staging environment test real behaviour but are slower and require infrastructure. The pragmatic approach: heavy unit testing for business logic, selective integration testing for coordination-critical paths, acceptance that some behaviours only manifest in production-like environments.

Chapter 5 covers specific patterns (mocking ctx.storage, testing hibernation wake behaviour, simulating concurrent requests). The principle: test your logic thoroughly, test coordination assumptions selectively, monitor production closely.

Comparing to hyperscaler alternatives

Understanding what Durable Objects replace clarifies their value.

PatternCloudflareAWSAzure
Coordination with consistent stateDurable ObjectsStep Functions + DynamoDBDurable Functions
WebSocket state managementDO with hibernationAPI Gateway + Lambda + DynamoDBSignalR + Functions
Rate limitingDO counterElastiCache or DynamoDBRedis Cache
Actor modelNativeCustom with SQS + LambdaOrleans (self-hosted)
Leader electionDO with leaseDynamoDB conditional writesBlob leases

The Cloudflare approach is simpler in every case. What requires multiple services on AWS (Lambda for compute, DynamoDB for state, ElastiCache for coordination) is a single Durable Object. Cognitive overhead, operational burden, and failure modes multiply with each additional service.

Real-time presence: a concrete comparison

On AWS, building a real-time presence system (who's online, cursor positions, live updates) typically requires API Gateway WebSocket API for connection management, Lambda functions for connection and message handling, DynamoDB for storing connection state and presence data with TTL for cleanup, and often ElastiCache for pub/sub to broadcast updates across Lambda instances. You manage connection routing, handle eventual consistency between DynamoDB and Lambda state, and pay for idle connections through API Gateway. Four distinct services with their own pricing models, failure modes, and operational concerns.

On Cloudflare, the same system uses a Worker for initial routing and a Durable Object per presence scope. The Durable Object holds WebSocket connections directly, maintains presence state in SQLite, and broadcasts updates to connected clients. Hibernation means idle connections cost nothing. No external pub/sub needed because all connections for a scope route to the same object. No eventual consistency issues because all state lives in one place with single-threaded access.

The difference isn't just fewer components. It's a fundamentally different model. AWS presence systems coordinate across distributed services, requiring careful handling of the gaps between them. Cloudflare presence systems centralise coordination in a single actor, eliminating the gaps.

The trade-off is lock-in. Durable Objects have no equivalent elsewhere. Migrating away requires redesigning your coordination layer. If PostgreSQL solves your problem, use PostgreSQL. Durable Objects exist for problems PostgreSQL can't solve.

Failure modes worth naming

Certain failures recur in Durable Object systems. Naming them creates vocabulary for design discussions and post-incident analysis.

Placement latency mismatch occurs when a Durable Object is placed based on its first requester, but subsequent users are in distant regions. A game session object first accessed in London is placed there. When players in Tokyo join later, every request crosses continents. Fix with location hints when you know optimal placement beforehand, or accept the latency. For objects serving globally distributed users equally, no placement is optimal. Consider whether the coordination requirement genuinely demands a single object.

Hibernation cold start affects the first request after a Durable Object hibernates. The object must wake, run its constructor, and reconnect WebSockets before processing. This typically adds 50-100ms to the first message. Subsequent messages while the object remains active are fast. If your application is latency-sensitive with sporadic traffic, hibernation cold starts may be noticeable.

Alarm drift occurs when alarms fire late. Under normal conditions, alarms fire within milliseconds of scheduled time. Under heavy load, delays up to one minute are possible. Design alarm handlers to be idempotent and tolerate timing variance.

State eviction surprise catches developers relying on in-memory state. It manifests as intermittent bugs in production but not local testing, where objects rarely go idle long enough to trigger eviction.

The god object anti-pattern

A team building a rate limiter creates one Durable Object for all users. It seems logical: centralised state, single source of truth, no coordination needed between objects. It works in staging.

In production, traffic grows. At 500 requests per second, latency increases. At 800, P99 latency exceeds a second. At 1,000, requests timeout. Single-threaded execution eliminates race conditions but creates a serialisation bottleneck when all requests funnel through one object.

The fix: embrace the model. One Durable Object per rate-limited entity. Each user's rate limiter lives in their own object. A million users means a million potential objects, but that's correct. Objects are database rows, not servers. Each handles only that user's traffic; the system scales horizontally without configuration.

Every Durable Objects anti-pattern shares this root cause: treating them like servers rather than database rows.

What comes next

This chapter covered Durable Objects as coordination primitives: the single-threaded actor model, output gating, hibernation economics, and the one-object-per-entity pattern that makes everything work.

Chapter 7 addresses Workflows, Durable Objects optimised for multi-step processes that must complete reliably over extended periods. Where Durable Objects coordinate concurrent access to shared state, Workflows orchestrate sequential steps spanning hours or days. The mental model differs, but the underlying durability guarantees connect directly to what you've learned here.

Chapter 8 covers Queues for asynchronous processing: work that doesn't need immediate response or Durable Objects' coordination guarantees. Chapter 9 covers Containers for workloads exceeding Workers' constraints entirely. Chapter 10 covers Realtime for audio and video streaming when WebRTC infrastructure is required. Together, these chapters complete Part III on stateful and durable systems.