DEV Community

juandisay
juandisay

Posted on Originally published at kgdev.me

Event Sourcing on Cloudflare Workers & Durable Objects — A Deep Dive Research

Overview

This is a research breakdown of Kieran Gray's excellent walkthrough on rebuilding FernLabour.com — a real-time labour tracking app — from a Python/GCP stack to Rust + Cloudflare Workers + Durable Objects, achieving a dramatic cost reduction from £93/month → £3.80/month.

The architecture implements CQRS + Event Sourcing where each labour runs inside its own Durable Object with its own SQLite event store, projections, and WebSocket subscribers.


What FernLabour Does

FernLabour is a labour tracking app: a mother in labour times contractions, posts updates, and pushes real-time notifications to family members. It supports:

  • Role-based access: birth partners can actively track contractions; observers receive updates passively
  • Multi-device participation: multiple people recording/updating simultaneously (distributed stopwatch problem)
  • Real-time delivery: web app, WhatsApp, or SMS notifications to subscribers

The Cloudflare Building Blocks

Primitive Role
Workers Serverless V8 isolate functions, true scale-to-zero, supports Rust/TS/Python
Durable Objects (DO) Stateful, single-threaded, one instance per aggregate ID, embedded SQLite (10GB)
DO Alarms Built-in background jobs with automatic retry (6x exponential backoff)
WebSocket Hibernation Thousands of open WS connections with zero idle compute cost
D1 Managed SQLite-as-a-service for cross-aggregate read models
Service Bindings Internal Worker-to-Worker calls without HTTP overhead

Why Durable Objects Fit Event Sourcing

The traditional pain points of event sourcing and how DOs solve them:

1. Concurrency / Lost Updates

Two commands hitting the same aggregate simultaneously can cause lost updates if both read state, apply logic, and append events independently.

✅ DO solution: Single-threaded execution per aggregate ID. Commands serialize automatically — no locks, no race conditions.

2. Dual Write Problem

Persisting events and publishing them separately risks losing data.

✅ DO solution: In-process SQLite for events + Alarms as the "outbox" mechanism with auto-retry. No separate message bus needed.

3. Projection Freshness vs Performance

Sync projections block writes; async projections cause stale reads.

✅ DO solution: Two-tier projector architecture (see below).


The Request Flow

Client → API Worker → Auth Service (service binding, no HTTP overhead)
       → Labour DO (routed by labour_id)
         ├── Load aggregate (cached snapshot, NOT full replay)
         ├── Apply command (FULLY SYNCHRONOUS — zero awaits!)
         ├── Append events to SQLite
         └── Set alarm(0ms) → return 204 immediately

Alarm (fires after response sent):
  1. Sync projectors → update DO-local read models
  2. WebSocket broadcast → push events to subscribers
  3. Async projectors → write to D1 (cross-aggregate)
  4. Process Manager → policies → effect ledger → dispatch
Enter fullscreen mode Exit fullscreen mode

The Critical Insight: Fully Synchronous Write Path

After HTTP deserialization, the entire write path — loading the cached aggregate, applying the command, appending events, updating the cache — runs with zero await calls.

Why? Every .await in the command path is a chance for a second command to slip in. Keep the path synchronous, and two concurrent commands execute in strict order. The second command sees the first command's events already applied.


Event Store Design

CREATE TABLE events (
    sequence INTEGER PRIMARY KEY AUTOINCREMENT,
    aggregate_id TEXT NOT NULL,
    event_type TEXT NOT NULL,
    event_data TEXT NOT NULL,
    event_version INTEGER NOT NULL DEFAULT 1,
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
    user_id TEXT NOT NULL
);
Enter fullscreen mode Exit fullscreen mode

One table per DO (per labour). Append-only, ordered by autoincrementing sequence. Nothing is ever updated or deleted.


Two-Tier Projector Architecture

Sync Projectors (Fast Path)

  • Write to DO-local SQLite (in-process, microseconds)
  • Each projector tracks its own checkpoint independently
  • Faulted after 5 consecutive failures (skipped, doesn't block others)
  • Used for: labour summary, contraction list, subscription tokens
  • Result: sub-millisecond query responses for per-labour data

Async Projectors (Cross-Aggregate)

  • Write to D1 via network
  • Maintain an in-DO cache; only write to D1 when the model actually changes
  • Flow: Load cached model → Fold new events → Diff → Write D1 only if changed
  • Used for: labour history page (data spans multiple DOs)

Key optimization: No need to replay all events or fetch from D1 each run. The cache holds the model locally; new events fold on top.


WebSocket Hibernation

Using DO WebSocket Hibernation mode:

  • DO can go dormant while WebSocket connections stay open
  • Cloudflare hands connections back when a message arrives
  • Thousands of labour DOs hold sockets for subscribers without burning idle compute
  • Client-side: WebSocket events are cache invalidation signals for React Query, not rendered directly

Process Manager: Events → Side Effects

Policies (Pure Functions)

Event + Aggregate State → [Effect]
Enter fullscreen mode Exit fullscreen mode
  • No side effects from the policy itself
  • Each effect carries an idempotency key built from (aggregate_id, sequence, discriminator)
  • Same policy running twice for the same event produces identical effects

Effect Ledger

CREATE TABLE pending_effects (
    effect_id TEXT PRIMARY KEY,
    idempotency_key TEXT NOT NULL UNIQUE,
    status TEXT NOT NULL DEFAULT 'PENDING',
    attempts INTEGER NOT NULL DEFAULT 0,
    ...
);
Enter fullscreen mode Exit fullscreen mode

INSERT OR IGNORE on idempotency_key prevents duplicates. The executor dispatches pending effects:

  • SendNotification → service binding to Notification Worker
  • IssueCommand → re-enters command processor (can produce new events)
  • GenerateSubscriptionToken → generates token + issues command

Failed effects retry up to 6x. Succeeded effects marked complete permanently.


Trade-offs & Lessons

Strengths

  • ✅ Global distribution automatic (DO placed near first request)
  • ✅ Zero infrastructure config overhead
  • ✅ Rust exhaustive match is perfect for event sourcing — compiler guides every refactor
  • ✅ Cost: £93/month → £3.80/month
  • ✅ No idle cost — true scale-to-zero

Limitations

  • ⚠️ Storage capped at 10GB per DO
  • ⚠️ ~1,000 req/s soft limit per DO
  • ⚠️ High vendor lock-in — architecture is Cloudflare-specific
  • ⚠️ workers-rs still rough; WASM binaries get large fast (2.2MB for this project)
  • ⚠️ New Cloudflare features land in TypeScript SDK before Rust

Author's Take on Vendor Lock-in

"If Cloudflare ever made this untenable, I'd rather shut the product down than migrate it."

The domain and application layers are pure Rust with no Cloudflare deps — those could port. The infrastructure adapters would need a full rewrite.


Patterns Worth Adopting Anywhere

Even if you don't use Cloudflare, several patterns from this architecture are universally applicable:

  1. Process Manager + Effect Ledger — cleaner than traditional outbox pattern, especially in single-threaded contexts
  2. Sync vs Async projector split — elegant solution to the "eventual consistency vs UX responsiveness" dilemma
  3. Fully synchronous write path — don't await in the middle of a command handler if you want free consistency guarantees
  4. Idempotency keys from (aggregate_id, sequence, discriminator) — makes replay and retry safe by construction
  5. WebSocket as cache invalidation signal — client never reconstructs state from event stream; server read models stay the source of truth

Research and analysis by Fabria — AI assistant powered by Hermes Agent. Original article by Kieran Gray.

Top comments (0)