If you run servers for a living, you have probably built the same stack a dozen times: an API server, Postgres for state, Redis for the hot path, a queue somewhere in the middle, and a scheduler to keep the stateless workers alive. It works. It is also five moving parts, each with its own failure mode, each needing its own operational brain.
celld, from Deno Land Inc, is an open-source daemon (Apache-2.0, currently a v0.6.x beta) that gives you a different shape to pick: it runs a Cloudflare Workers application on your own machines. Your existing wrangler project deploys unchanged, with Workers, Durable Objects, KV, Queues, D1, R2, Workflows, Cron Triggers, and static assets.
The long-term state lands in an object store you already own: any S3-compatible bucket, Google Cloud Storage, or Azure Blob. No control plane, no consensus service, no Postgres instance to babysit at 3 a.m.
The idea in one paragraph
The Durable Object model, designed by Kenton Varda's team at Cloudflare, is simple and beautiful: every object is a named, single-threaded little server with its own database.
Sessions, games, ledgers, agents: one object per entity, addressed by name, with SQLite storage behind it and zero coordination code between competing writers. celld's bet is that this primitive does not belong in one cloud: it is V8, plus SQLite, plus LTX (Litestream's replication format), all built on top of a plain bucket. The fleet shares one object store, and a conditional write on that store assigns ownership. Be precise about the consensus claim, then: you run no consensus service, but the bucket's compare-and-swap is doing that job. The design outsources the hard part to storage that already provides it.
- celld
v0.6.2 beta (celld.dev, GitHub README checked 2026-10-12)
All numbers below come from the project's own published measurements, not my bench. I have not run a fleet yet; treat this as a tutorial-adjacent read of the docs, and read the limitations and security pages before operating a public fleet.
What it looks like
$ # your load balancer sprinkles requests over the fleet
$ # each celld node embeds V8; every cell owns a private SQLite db
$ # durable writes replicate to a peer or straight to the bucket
clients --> [ load balancer / TLS ]
| |
node A (celld) node B (celld)
cell 1..N cell N+1..M <- exactly one owner per cell
\ /
v v
[ shared object store: S3 / GCS / Azure ]
cell state + ownership leases + deployments
$ # node dies? another node takes its cells from the bucket,
$ # no membership protocol and no consensus layer involvedA cell hibernates when idle, which is why the project cites a resting cost near zero per inactive cell. Density is the pitch, and the project states it two ways: the homepage advertises 2,500 resident cells on one 8 GB node (about $0.02 per resident cell-month on a $48 node), while the operations docs plan for 1,000 (about $0.05). Reconcile them with the measured number: 2,500 cells at 0.47 MB of per-cell RSS is only about 1.2 GB, so cell state is not what fills the box. The rest of the node pays for the V8 runtime itself, SQLite page caches, replication buffers, and the headroom a node needs before it starts shedding cells. Plan with the conservative number.
One line item the homepage explicitly excludes is bucket request charges: every durable write eventually becomes an upload to your bucket, batched by the node, so a write-heavy fleet pays object-storage request pricing that an idle fleet never sees. As an illustration at AWS list prices, one million upload operations cost about $5; how many uploads your writes produce depends on batching, so estimate from measured write volume, not request counts.
How it differs from Postgres + Redis
That classic stack separates compute from state. celld fuses them per entity, which changes almost every operational decision:
| Feature | App + Postgres + Redis | celld |
|---|---|---|
| State model1 | One shared Postgres plus a cache tier you tune per hot key | One single-writer SQLite per named object |
| Concurrency story | You design locking, versioning, or queueing yourself | One actor, one writer, so races are a design smell not a race to fix |
| Consistency of a hot feed or session2 | Redis adds speed but also staleness and a second truth | Durable write per cell, RPO 0, acked writes survive a node kill |
| What you brute-force at scale | Read replicas, sticky sessions, partitioning, more caches | More nodes in the fleet; the bucket distributes ownership |
| Idle cost | Postgres and Redis bill whether or not your app has users | An inactive cell costs roughly nothing (~$0.02/month bucket ops, project figure) |
| Ops surface | Database, cache, queue, scheduler: four dashboards to watch | One daemon per machine, plus one object store you already run |
State model1
- App + Postgres + Redis
- One shared Postgres plus a cache tier you tune per hot key
- celld
- One single-writer SQLite per named object
Concurrency story
- App + Postgres + Redis
- You design locking, versioning, or queueing yourself
- celld
- One actor, one writer, so races are a design smell not a race to fix
Consistency of a hot feed or session2
- App + Postgres + Redis
- Redis adds speed but also staleness and a second truth
- celld
- Durable write per cell, RPO 0, acked writes survive a node kill
What you brute-force at scale
- App + Postgres + Redis
- Read replicas, sticky sessions, partitioning, more caches
- celld
- More nodes in the fleet; the bucket distributes ownership
Idle cost
- App + Postgres + Redis
- Postgres and Redis bill whether or not your app has users
- celld
- An inactive cell costs roughly nothing (~$0.02/month bucket ops, project figure)
Ops surface
- App + Postgres + Redis
- Database, cache, queue, scheduler: four dashboards to watch
- celld
- One daemon per machine, plus one object store you already run
- No cache invalidation puzzle: the data of an entity lives with that entity
- Project-measured: ~0 lost acks on kill
To be fair to the old stack, it is the right tool when many heterogeneous services need one queryable source of truth. celld shines when your state actually decomposes into per-entity blobs, which is most workflow-style apps.
The real switch: the entity becomes the database
Strip the marketing and both stacks answer one question: who is allowed to write this state right now? The classic stack answers it with machinery around a shared database: row locks, version columns, lease tables, caches that promise to be invalid someday. The cell model answers it with placement. The state and the one thread allowed to touch it live in the same object, and a conditional write in the bucket enforces that there is only one. That is the whole switch; everything after it is consequences.
Here is the same operation, deducting credit for one user, in both worlds:
$ # classic: every app server serializes writers against one shared row
BEGIN;
SELECT credit FROM accounts WHERE user_id = 42 FOR UPDATE;
UPDATE accounts SET credit = credit - 5 WHERE user_id = 42;
COMMIT;
-- around it: cache invalidation, retry on lock conflicts,
-- sticky sessions so the hot user lands on one app server
$ # celld: the cell is named "account:42"; the cell IS the writer
class Account {
deduct(n) {
const row = this.ctx.storage.sql
.exec("SELECT credit FROM accounts").one();
this.ctx.storage.sql
.exec("UPDATE accounts SET credit = ?", row.credit - n);
}
}
-- no lock to take: one thread owns the row, storage calls never
-- interleave, and the response waits until the write is durableWhat you stop writing is a list worth reading slowly: advisory locks and SELECT FOR UPDATE to serialize writers, optimistic version columns with retry-on-conflict loops, cache invalidation logic, sticky sessions, LISTEN/NOTIFY or a pub/sub bus so other processes learn that a row changed, and the outbox-plus-worker table that turns do-it-once into a polling job. A cell hands you the primitive all of that was imitating: one thread, storage that never interleaves, and an acknowledgement that waits for durability.
The price is partition discipline, and it deserves equal billing:
- No cross-entity queries. There is no join between user 42 and user 43; anything that needs a whole-fleet view has to leave the cell on an export path.
- Naming becomes data modeling. idFromName is the entire addressing scheme: the same name resolves to the same cell from any node, forever. What you used to call a shard key is now your schema.
- One cell is one thread. A cell admits at most 64 concurrent requests, then answers 503 with Retry-After; the cap is per cell, not per fleet.
- Related state must be colocated. A room and its messages fit one cell. A query across all rooms does not exist. Decide what one entity is before you deploy, because that decision becomes load-bearing.
None of that is unique to celld; it is the admission price of every actor or sharded design. The difference is that the runtime enforces the discipline here, instead of a code review.
The use cases that fit
- Realtime collaboration and chat: one cell per room, its messages in SQLite, its WebSockets hibernating between events.
- Game and multiplayer state: tick logic in an actor, room takeover across your machines instead of a matchmaking service.
- Agents and long tasks: durable queues, steps, sleeps, and checkpoints in Workflows, so a restart resumes rather than re-runs.
- Rate limiters, ledgers, IoT ingest: small per-key state where single-writer semantics beat a shared cache.
- Homelab apps you've written for wrangler and want to keep running off Cloudflare.
Agents: a fleet that sleeps
The docs' cleanest fit is an agent fleet, and the reason is economic. One agent is one cell: its memory, schedule, and inbox live in its own SQLite database. Between events the agent is not a process anywhere: celld removed it from memory, and the cell is an object in your bucket. The traditional self-hosted alternative keeps every agent resident, a container or a scheduler row apiece, and charges you to run ten thousand do-nothing loops.
Long work belongs in Workflows: durable steps, sleeps, and restarts, so a node dying mid-run resumes instead of re-runs. Alarms are the scheduler, and they inherit the same durability rule: the response that sets an alarm is not sent until a durable wake entry covers it, so the wake-up survives the machine that scheduled it.
$ # the life of one agent cell, "agent:ada"
09:14 email arrives -> a node wakes the cell, appends to the inbox (durable)
09:14 the agent works: LLM call, draft reply, write memory to SQLite
09:15 reply sent; setAlarm schedules the 09:30 follow-up; cell leaves memory
idle from here on: no resident process, near-zero bucket ops
09:30 the alarm fires on whichever node owns the cell now
$ # 100,000 agents shaped like this are a handful of resident cells
$ # right now; the other 99,990 are objects in the bucketChat rooms and collaborative documents: hibernating WebSockets
A room is one cell, and the interesting part is the quiet room. A hibernated cell keeps its WebSocket clients connected while holding no server code in memory: the sockets park on the node, the next message wakes the cell, the handler appends to SQLite, and the fan-out runs from the one place that knows the room's true order. That ordering is precisely what the classic stack makes you assemble by hand: a Redis list or stream per room, a Lua script for atomic fan-out, and a reconciliation story for the day the cache and Postgres disagree. In a cell, history, presence, and order are rows in one database that one thread mutates.
$ # quiet state
cell: hibernated RAM: freed websockets: 12 clients parked on node B
$ # user 7 sends a message
1 any node receives it, resolves the name, routes to the owner (node B)
2 the cell wakes (~4 ms); the handler runs:
INSERT INTO messages ... durable, RPO 0
3 fan-out to all 12 sockets from the same thread
4 the idle timer passes -> hibernate again; the parked sockets stay parked
$ # a rebalance closes parked sockets with code 1012 so clients reconnect;
$ # a dead node simply drops them. either way the name finds the new ownerPer-key counters: rate limiters and ledgers
One cell per API key, per account, per device. The canonical rate limiter is a token bucket in a single cell, and the reason it works is the single-writer guarantee: a counter that only one thread can mutate needs no lock, so there is no INCR herd hammering Redis and no nightly job reconciling the Redis approximation with the Postgres truth. Ledgers are the same shape with higher stakes: append inside the cell, acknowledge after the durability proof, and the epoch-fenced writer means a failed node cannot come back as a second head.
What a node death actually looks like
The bucket-as-control-plane claim is easiest to check on failure. Walk the timeline:
$ # node A dies hard: kernel panic, no shutdown handoff
t+0s A stops renewing its lease (CELLD_TTL_MS, default 10 s)
t+~10s the lease expires in the bucket; A's claims vanish on their own
t+~10s traffic for one of A's cells reaches node B:
B writes the ownership record with a condition (compare-and-swap)
B restores the cell's SQLite from the LTX log in the bucket
t+~15s the cell serves again; every acked write is in it (RPO 0)
$ # the failover coordinator is the same conditional write that
$ # assigns ownership in the first place. there is no other serviceTwo caveats keep this honest. Failover takes about 15 to 20 seconds, so a cell is durable but not instantly available during a node loss. And a caller that was mid-request when the node died gets an ambiguous failure: celld retries only work that provably never started, so your client must retry with a stable operation ID. That is the one retry rule the model asks you to learn.
The honest edges: where celld does not fit
Numbers first, because they decide architectures: a single-node fleet answers every durable write after a full bucket round trip, about 90 ms region-local (~25 ms with a second node as peer); failover of a lost node takes about 15 to 20 seconds; and the whole thing is a v0.6 beta. Then the boundaries that no tuning removes:
- Analytical workloads. The model is transactional: one entity, one writer, no joins. Aggregation belongs in a warehouse or a read model, not in the fleet.
- One globally hot entity. The 64-request admission cap is per cell, so a single viral object cannot be scaled by adding nodes; shard it by design or accept the ceiling.
- The Cloudflare edge. Anycast distribution, GPUs, and browser farms stay out of scope. You get the programming model, not the network.
- Upgrade-day planning. Some version transitions are stop-all (v0.5.1 to v0.6.0 with fleet durability, for example), so treat releases the way you treat a database major-version migration.
How it compares to the tools you already know
If you have self-hosted Cloudflare's own runtime, workerd, you already run stateless Workers on your own box. What workerd does not give you is the stateful half: no cell ownership, no leases, no fleet, no bucket replication. celld is that missing half, and it embeds V8 directly rather than wrapping workerd, which is why its compatibility boundary is its own, documented per service.
Actor frameworks are the other close relatives, and Orleans in particular is conceptually near-identical: grains are named, single-threaded stateful entities with durable state, which is the cell model with a .NET accent. Akka clustered actors are the JVM version with more machinery. The real trade is ecosystem and language, not shape: Orleans and Akka bring mature tooling for their platforms, while celld keeps the Workers programming model and the wrangler deploy path, so the same application still runs on Cloudflare if you ever want the network back.
Temporal solves a different problem again: durable execution of long-running orchestrations, with event-sourced histories in a real database cluster behind it. It is heavier to operate, and it is not a per-request state server. The two compose well: Temporal, or celld's lighter built-in Workflows, for multi-step business processes; cells for the hot per-entity state those steps act on.
Operating one: the two-minute tour
The sysadmin questions have short answers. Health is a boolean on the public listener. The real telemetry is GET /state on the internal listener, whose node_load block is the add-a-node signal: capacity_waiting above zero, or a pressured flag, means the fleet wants another machine. celld diagnose reads every lease and probes every peer without touching ownership. celld cell list finds the loud cell, and when a hot one starts shedding, refusals are logged per cell. Backups are the bucket: cell state and LTX logs already live there, so bucket versioning or cross-region replication is your backup story, and a restore is a fresh node pointed at the same bucket. One warning from the docs belongs in every runbook: the internal listener is unauthenticated and peer traffic is not encrypted, so it belongs on a private network or an encrypted overlay.
$ # is the fleet healthy?
curl -s http://node-a:8080/.well-known/celld/health
celld diagnose --bucket "$CELLD_BUCKET" --endpoint "$S3_ENDPOINT" --region "$AWS_REGION"
$ # do I need another node? (internal listener)
curl -s http://node-a:8081/state | jq '.node_load | {capacity_waiting, pressured, resident_cells}'
$ # which cell is the loud one?
celld cell list --json --bucket "$CELLD_BUCKET" | jq -r '.scope'
$ # backups: the bucket already holds every cell's LTX log.
$ # enable versioning or cross-region replication on it; a restore is
$ # a fresh node pointed at the same bucketVerdict
celld is a credible answer to an old sysadmin wish: Durable Objects that run on machines you pay for, with the bucket you already trust as the source of truth. It is a beta with real edges, and it does not replace Postgres for relational work; the FAQ above is the honest deployment shape. But if your state genuinely decomposes into per-entity units, it is worth a weekend prototype. Keep Postgres for everything relational, and move the hot per-entity state only where the contention actually shows up.
FAQ
Does celld require Deno?
No: it embeds V8 and executes Wrangler bundles, so a standard Cloudflare Workers project deploys with its own tooling.
Is it a drop-in for every Cloudflare binding?
Not quite: the compatibility page documents gaps per service, and anything depending on Cloudflare's network, GPUs, or browser farm is out of scope.
How durable is a write really?
Per the project's guarantees doc: epoch-fenced single writer, RPO 0; a write is durable once a peer (or, single-node, the bucket) holds its LTX log.
Do I have to move everything off Postgres?
No, and you probably should not. The natural shape keeps the relational core (users, billing, the genuinely relational long tail) in Postgres and moves the per-entity hot state into cells: sessions, rooms, agent memory, per-key counters. If you want SQL inside the fleet, celld ships D1 as a cell with the same lease, replication, and failover as any other. The switch is about where contention lives, not a big-bang migration.
Can cells talk to each other?
Yes, through the ordinary Workers surface: JS RPC, service bindings, HTTP, or a queue. What they cannot do is join each other's storage. Two cells are two databases, so a cross-entity read is an API call, and you design for it: keep what you query together in one cell, and push aggregation to an export path.
Further reading
- Project and README: https://github.com/denoland/celld
- Durability guarantees and protocol: https://celld.dev/docs
- Cost model: https://celld.dev/
- Known gaps vs Cloudflare: https://celld.dev/docs/cloudflare-compat
- Limitations and operational requirements: https://celld.dev/docs/limitations
Until next time, keep your systems thoughtful.

No comments yet