Durable Objects Storage and the Compatibility Date System
Prerequisites
- ›Articles 1-4 (full understanding of the runtime architecture, request lifecycle, and JSG bindings)
- ›Understanding of database consistency models (linearizability)
- ›Familiarity with SQLite basics
- ›Knowledge of Cap'n Proto annotations
Durable Objects Storage and the Compatibility Date System
We've traced workerd from boot to request handling to JSG bindings. Now we turn to two subsystems that make the runtime production-ready at scale: Durable Objects, which implement the actor model with a gate-based consistency mechanism that guarantees linearizability without explicit user-side locking, and the compatibility date system, which tracks every behavior change as a dated boolean flag, enabling the "never break old code" promise. These are the features that separate workerd from a toy runtime.
The Actor Model in workerd
Durable Objects are workerd's implementation of the actor pattern. Each Durable Object is a single-threaded, stateful entity identified by a unique ID. It has its own persistent storage, its own IoContext, and a guarantee: no two requests to the same Durable Object execute concurrently.
The Worker::Actor class implements this:
src/workerd/io/worker.h#L839-L920
An Actor is created by an ActorNamespace inside WorkerService. The namespace maintains a map of active actors, keyed by ID. When a request arrives for an actor, the namespace either returns the existing instance or creates a new one:
src/workerd/server/server.c++#L1934-L1958
stateDiagram-v2
[*] --> Idle: Actor created
Idle --> Active: First request arrives
Active --> Active: More requests queued
Active --> Draining: No pending requests
Draining --> Idle: All tasks complete
Draining --> Active: New request arrives
Active --> Broken: Storage error or gate failure
Broken --> [*]: Actor evicted
Unlike stateless workers (which get a fresh IoContext per request), actors reuse their IoContext across requests. As we saw in Part 3, WorkerEntrypoint::init() checks for an existing actor IoContext and reuses it. This is what makes actors stateful — in-memory state persists between requests.
The Actor::Id type is a kj::OneOf<kj::Own<ActorIdFactory::ActorId>, kj::String> — either a system-generated unique ID or a user-provided string name. The Loopback interface allows sending requests to an actor even after the Actor object itself might have been evicted and recreated.
I/O Gates: InputGate and OutputGate
The two-gate system is workerd's solution to Durable Object consistency. The problem: JavaScript is single-threaded, but await introduces interleaving points. Between await storage.get("key") and await storage.put("key", newValue), another request could execute and corrupt the state.
src/workerd/io/io-gate.h#L1-L120
The solution uses two gates:
InputGate blocks all incoming events while storage operations are in-flight. When you call storage.get(), the InputGate is locked. Until the read completes, no other request's event handlers, no subrequest responses, no timer callbacks — nothing external can execute. This prevents TOCTOU races without requiring the developer to use explicit locks.
OutputGate blocks outgoing messages until writes are confirmed on disk. When storage.put() is called, the write is queued and the OutputGate is locked. The response to the client, subrequest initiations — anything that would allow the outside world to observe the actor's state — is held until the write is durable. If the write fails, the messages are never sent, preventing the outside world from observing a state that was never actually persisted.
sequenceDiagram
participant R1 as Request 1
participant IG as InputGate
participant OG as OutputGate
participant S as Storage
participant R2 as Request 2
R1->>IG: Lock (storage.get starts)
R2->>IG: Wait... (blocked)
S-->>R1: get() result
IG->>IG: Unlock
R2->>IG: Lock acquired
R1->>OG: Lock (storage.put starts)
R1->>R1: Continue execution
R1->>OG: Try to send response (blocked)
S-->>OG: put() confirmed durable
OG->>OG: Unlock
R1-->>R1: Response released to client
The InputGate::CriticalSection extends this with a stronger guarantee: it's a procedure that must complete atomically from the perspective of other events. If a critical section fails, it permanently breaks the InputGate — all future requests to this actor will fail. This prevents the actor from continuing in an inconsistent state:
src/workerd/io/io-gate.h#L172-L220
This design means Durable Objects achieve linearizability — the strongest consistency guarantee — by default, with zero effort from the developer. The storage API looks simple (get, put, delete), but the gates ensure that the concurrent execution model is safe.
ActorCache: In-Memory Caching with Durability Options
Between JavaScript and disk storage sits the ActorCacheOps interface:
src/workerd/io/actor-cache.h#L29-L100
The cache provides two important option structs:
ActorCacheReadOptions: The noCache flag tells the cache not to store the result of a disk read — useful for large values you'll read once.
ActorCacheWriteOptions: allowUnconfirmed tells the OutputGate not to wait for this write to be confirmed on disk. This is a performance escape hatch — it allows the response to be sent before the write is durable, at the cost of potentially losing the write. noCache evicts the value from cache once it's safely on disk.
flowchart TB
subgraph "JavaScript API"
GET["storage.get(key)"]
PUT["storage.put(key, value)"]
end
subgraph "ActorCache Layer"
Cache["In-memory cache"]
Dirty["Dirty tracking"]
Flush["Flush to storage"]
end
subgraph "Storage Backend"
RPC["Cap'n Proto RPC<br/>(ActorStorage::Stage)"]
SQLite["ActorSqlite<br/>(local SQLite)"]
end
GET --> Cache
Cache --> |miss| RPC
Cache --> |miss| SQLite
PUT --> Cache
Cache --> Dirty
Dirty --> Flush
Flush --> RPC
Flush --> SQLite
The cache batches writes intelligently. Multiple put() calls within a single event handler execution (before any await) are coalesced into a single flush. The flush is what triggers the OutputGate lock — and because the writes are batched, the gate opens sooner.
ActorSqlite: SQLite-Backed Storage with OutputGate Integration
For local workerd deployments (and for Cloudflare's SQL API), ActorSqlite provides a SQLite-backed implementation of the ActorCacheInterface:
src/workerd/io/actor-sqlite.h#L16-L80
The design insight is automatic transaction batching. Multiple writes that happen without any await in between are automatically grouped into a single SQLite transaction. This is accomplished by scheduling a transaction commit after the current event handler tick:
sequenceDiagram
participant JS as JavaScript
participant AS as ActorSqlite
participant DB as SQLite
participant OG as OutputGate
JS->>AS: put("a", 1)
AS->>DB: INSERT (in implicit transaction)
JS->>AS: put("b", 2)
AS->>DB: INSERT (same transaction)
JS->>AS: put("c", 3)
AS->>DB: INSERT (same transaction)
Note over AS: Event handler tick ends
AS->>DB: COMMIT
AS->>OG: commitCallback() → Lock
DB-->>AS: Commit confirmed
OG->>OG: Unlock (outgoing messages released)
The commitCallback parameter in ActorSqlite's constructor is where OutputGate integration happens. After committing a transaction, the callback is invoked — and its returned promise holds the OutputGate until replication (or whatever durability guarantee) is confirmed. For local deployments, this resolves immediately after the SQLite COMMIT. For Cloudflare's production system, it waits for the write to be replicated.
The class also manages alarm scheduling via the Hooks interface, where alarm time changes are coordinated with transaction commits to ensure atomicity between data operations and alarm scheduling.
The Compatibility Date System
Now let's turn to the other major subsystem: compatibility dates. The schema lives in a single, massive file:
src/workerd/io/compatibility-date.capnp#L21-L76
The CompatibilityFlags struct uses Cap'n Proto annotations to attach metadata to each boolean field:
$compatEnableFlag("name"): The flag name that explicitly enables this feature$compatDisableFlag("name"): The flag name that explicitly disables it$compatEnableDate("YYYY-MM-DD"): The date after which this flag is enabled by default$experimental: Requires--experimentalCLI flag$compatEnableAllDates: Enabled for all dates (used for corrections to pre-release behavior)
Here's a concrete example:
src/workerd/io/compatibility-date.capnp#L80-L95
formDataParserSupportsFiles @0 :Bool
$compatEnableFlag("formdata_parser_supports_files")
$compatEnableDate("2021-11-03")
$compatDisableFlag("formdata_parser_converts_files_to_strings");
# Our original implementations of FormData made the mistake of turning
# files into strings. We hadn't implemented `File` yet, and we forgot.
This tells us: workers with compatibilityDate >= "2021-11-03" get the correct behavior (files are File objects). Workers with older dates get the bug (files become strings). Either behavior can be explicitly forced with the enable/disable flags.
flowchart LR
Config["Worker config<br/>compatibilityDate: '2023-03-01'<br/>compatibilityFlags: ['url_standard']"]
--> Parse["Parse date + explicit flags"]
--> Resolve["For each flag in schema:<br/>1. Check explicit enable/disable<br/>2. Check date >= enableDate"]
--> Reader["CompatibilityFlags::Reader<br/>(all bools resolved)"]
--> API["API implementations<br/>branch on flags"]
Compatibility Flags Flow: Config to API Branches
The flow from config to runtime behavior branches is straightforward but spans multiple files. It starts in the configuration:
A Worker's compatibilityDate and compatibilityFlags are declared in the Cap'n Proto config. During worker construction, these are resolved into a CompatibilityFlags::Reader — a Cap'n Proto reader with a boolean getter for every flag.
The WorkerdApi class is the conduit:
src/workerd/server/workerd-api.h#L38-L78
WorkerdApi takes CompatibilityFlags::Reader features in its constructor and stores it. This reader is then injected into the JSG isolate type via jsg::InjectConfiguration<CompatibilityFlags::Reader>, making it available to every JSG_RESOURCE_TYPE block that declares a configuration parameter.
In API implementations, the branching looks like this:
JSG_RESOURCE_TYPE(Navigator, CompatibilityFlags::Reader reader) {
JSG_METHOD(sendBeacon);
JSG_READONLY_INSTANCE_PROPERTY(userAgent, getUserAgent);
if (reader.getEnableNavigatorLanguage()) {
JSG_READONLY_INSTANCE_PROPERTY(language, getLanguage);
}
}
The if statement means the language property literally doesn't exist on the JavaScript object unless the flag is enabled. This is a structural change, not a runtime check — the V8 template doesn't include the property at all.
For runtime behavior changes (not structural API changes), the flag is checked in the method body:
void someMethod(...) {
auto& flags = IoContext::current().getWorker()
.getScript().getIsolate().getApi().getFeatureFlags();
if (flags.getNewBehavior()) {
// new behavior
} else {
// old behavior
}
}
flowchart TD
Schema["compatibility-date.capnp<br/>1500+ lines of flags"] --> Compile["Cap'n Proto compiler<br/>generates C++ accessors"]
Compile --> Reader["CompatibilityFlags::Reader"]
Reader --> WApi["WorkerdApi constructor"]
WApi --> JSG["JSG InjectConfiguration"]
JSG --> RT["JSG_RESOURCE_TYPE blocks<br/>structural branching"]
WApi --> Impl["API method bodies<br/>runtime branching"]
The $experimental annotation adds a second gate: even if a worker specifies the flag, it won't take effect unless the workerd process was started with --experimental. This is enforced during config validation in the server. Experimental flags cannot be used on Cloudflare's production infrastructure except by internal test accounts.
Tip: When adding a new compatibility flag, follow this process: (1) add the flag to
compatibility-date.capnpwith$compatEnableFlagonly — no date yet; (2) implement the behavior behind the flag; (3) once it's tested and stable, add$compatEnableDatewith the current date; (4) always add a$compatDisableFlagso workers can opt out. And don't forget to add test cases incompatibility-date-test.c++.
The compat system is why workerd's version number is a date. Each release's version date indicates the maximum compatibility date it supports. Users set their compatibilityDate to whatever date they've tested against, and the runtime faithfully emulates that era's behavior — even as the underlying code evolves.
This concludes our five-part deep dive into the workerd codebase. We've traced the path from CLI invocation through config parsing, V8 initialization, service graph construction, socket binding, request handling, JSG bindings, Durable Object consistency, and compatibility dates. The codebase is large, but its architecture is principled: layers compose cleanly, every Service implements the same interface, and the compatibility system ensures that the code you wrote yesterday will still work tomorrow.