celld runs the Workers runtime (module Workers, fetch, JS RPC, service bindings, Durable Objects, static assets, KV, Queues, Workflows, R2, Dynamic Workers, facets, and containers) with Durable Objects as the stateful core. The scope rule again: a configuration or binding that is not available must fail loudly, at deploy or first use; a silent gap is a bug. Each service has its own page under docs/services/, with a how-it-works narrative, a worked example, and a "differences from Cloudflare" list, while the runtime-API sections stay on the compatibility page. The pages list only an unavailable feature, a celld-specific limit, or an observable difference: "if an entry has no note, celld intends to match the linked Cloudflare API". Each surface is graded Yes (implemented, except the listed differences), Partial (a substantial part unavailable), Experimental (can change without notice), or No. On that scale every service celld carries is Yes except Containers (Experimental). In the runtime table only Node.js compatibility and the Cache API are Partial, and BroadcastChannel is the lone No.
Runtime API surface: the parts that matter
| Surface | Status on celld |
|---|---|
| Fetch / Request / Response / Headers | Yes. The cache request option is unavailable; celld removes Content-Length from a Worker response (preserves it for HEAD); Headers accepts values above U+00FF and decodes response values as UTF-8 |
Context (ctx) | Yes. passThroughOnException() is a no-op; ctx.facets is available only inside a Durable Object; ctx.exports holds default and each entrypoint, and fetch() on its stubs sends an HTTP request |
| Main module | As in workerd, every export of the main module must be a handler object or a class; export const X = "..." makes the Worker fail to start |
| Handlers | Yes. fetch, alarm, scheduled, queue, WebSocket handlers, RPC; tail and email unavailable |
| JS RPC | Yes. A stub cannot cross an isolate boundary; an AbortSignal passes through on the same node but not across a node boundary; retries only when the peer attempt provably did not start |
| Streams / Encoding | Yes. An HTTP stream expires after 60 s unclaimed or inactive (renewed by successful reads), and an expired or unknown stream errors rather than reporting EOF |
| WebSockets | Yes. Inbound (hibernatable, with attachments and tags) and outbound; a 1 MiB per-isolate input budget per non-terminal frame; a transport cannot move to a new owner, so reconnect with a stable operation ID; a mid-frame connection failure closes with 1012; the output gate holds each frame only for its own proof, so a webSocketMessage() handler can stream frames while it runs; a close is wasClean: true whenever the peer sent a close frame |
| Web Crypto | Yes, including wrapKey/unwrapKey, RSA signing, HKDF/PBKDF2 derivation, and Ed25519 (also spelled NODE-ED25519) and X25519 with raw import and export of the 32-byte point. Remaining limits are algorithm-specific: ECDSA P-256 with SHA-256 only, AES-GCM tags 96–128 bits, a secret key cannot use jwk with exportKey()/wrapKey(), and X25519 rejects a low-order peer key |
node: imports | Partial. assert, async_hooks, buffer, diagnostics_channel, events, path, stream, timers/promises, util, os; crypto/zlib partial; node:fs covers access/mkdir/realpath/stat/readFile over a per-request /tmp and a read-only /bundle; http, net, tls, dns import but throw on first call; the bundler honors synchronous CommonJS require() of built-ins |
| D1 | Yes. A cell; one writer; results capped at 100,000 rows / 32 MiB; a TEXT value that is not valid UTF-8 decodes with U+FFFD, as in workerd (the stored bytes do not change) |
| KV · Queues · Workflows · R2 | Yes (remaining differences below) |
| HTMLRewriter · TCP sockets · EventSource · MessageChannel | Yes. A TCP socket cannot outlive its event (a Durable Object reconnects next event); TLS is verified against a bundled Mozilla root store; celld does not block the ports Cloudflare blocks, so the fleet network controls egress |
| Cache | Partial. An always-miss cache: put() validates and consumes the response but stores nothing, match() returns undefined, delete() returns false |
| BroadcastChannel | No. The class is defined so a bundle loads, but its constructor throws rather than acting as a silent stub |
Dynamic Workers, facets, and containers
Dynamic Workers is the Worker Loader under its current name, graded Yes. A deployment declares its loaders in wrangler.jsonc: "worker_loaders": [{ "binding": "LOADER" }]. A loaded Worker can receive Service Binding capabilities through WorkerCode.env alongside structured-clone values (1 MiB total), so a parent can hand a child a ctx.exports entrypoint to call back on; getEntrypoint() and getDurableObjectClass() take only props. The process holds at most 256 live Dynamic Workers, 255 per script generation, and that limit is not tunable; module sources total at most 64 MiB. globalOutbound cannot connect() or open a WebSocket. WorkerCode.limits (and the limits option of getEntrypoint()) enforces cpuMs and subRequests. A WorkerCode.tails array of Service Binding Fetchers receives one invocation report per finished fetch (request metadata, response status, up to 256 KiB of console records, the uncaught exception, and the outcome), delivered after the response, with a Tail failure logged rather than surfaced. allowExperimental is rejected. The Wrangler worker_loaders entry itself accepts only binding; limits and tails belong in the WorkerCode object, and the deploy stops if they appear in the config entry. Three rules match workerd exactly and can break code written loosely against an earlier release: WorkerCode requires compatibilityDate, a wasm entry in modules must be { wasm: bytes } (bare bytes are refused), and a relative import inside a module subdirectory resolves from the importing module's name.
Durable Object facets: ctx.facets.get()/abort()/delete() attach a child object with its own SQLite database. The class comes from a Worker Loader binding (worker.getDurableObjectClass()) or from ctx.exports for a DurableObject class the Worker exports without a storage migration; that facet runs in the root's isolate. A Durable Object binding cannot supply one. Each facet lives in its own SQLite file with its own replication stream under the root cell's bucket prefix, sharing the root's ownership record and epoch. The consequence: a facet write commits in the facet's own database, so rolling back a root transaction does not undo a facet call inside it. An application that needs one atomic commit must keep that state in one database. celld holds a facet's outbound effects and replies until the facet's own stream proves the call's writes, and a move proves every facet stream before the new owner opens the root. clone() is unavailable. Two more limits: a facet cannot set an alarm (storage.setAlarm() throws inside one, so the root holds the schedule), and facets nest to a total depth of four counting the root, with names up to 256 bytes. The examples/facets project shows the whole pattern, including the callback capability.
Containers are Experimental: "the configuration keys, the ctx.container surface, the node-side defaults, and the security boundary can change without notice". A container makes a Durable Object the supervisor of one container, driven by @cloudflare/containers as published; the Sandbox SDK (@cloudflare/sandbox, on the cloudflare/sandbox image) runs on top unchanged. A containers entry accepts exactly class_name, image, name (accepted, unused), instance_type, max_instances, and the celld-only runtime override; the class must be a SQLite-backed Durable Object of the same script. celld deploy builds or pulls the image with the Docker or Podman CLI (CELLD_DOCKER), for linux/amd64 by default (CELLD_CONTAINER_PLATFORM), saves it once to the bucket under deploy/images/, and every node loads it on first use; each fleet node needs a container engine socket. instance_type maps to Cloudflare's CPU and memory tiers: lite (alias dev, the default: a sixteenth of a CPU and 256 MiB), basic, standard-1 through standard-4. Disk size is not enforced. max_instances caps a class fleet-wide through the shared node sample, so it can overshoot by one refresh cycle. The node fences container bridges with nftables before the first start: enableInternet: true reaches only the Internet, never the node, its peers, or private ranges, and false is an internal bridge with no route out. Container disk is ephemeral; it dies with a move, restart, or reset. Idle eviction respects setInactivityTimeout() (default 10 minutes). Implemented: running, start(), monitor(), destroy(), signal(), getTcpPort(), exec(), setInactivityTimeout(); not implemented: inspect(), snapshots, and outbound interception. A sandbox that moves nodes loses its container, so the first call after a move can throw the SDK's OperationInterruptedError, matching Cloudflare's own restart behavior.
KV
- No edge cache.
cacheTtlhas no effect andcacheStatusisnull. KV is a durable store, not a CDN. - A value above 1 MiB requires the fleet bucket (small values are in-cell). Large values are stored under their ownership epoch with an epoch-qualified row reference, so an old owner cannot delete the current value.
- One writer per namespace. Add namespaces, not writers. A namespace ID accepts the Cloudflare hex form or any stable string.
- Operate with
celld kv get/put/delete/listandbulkvariants (Wrangler file format, so it interops withwrangler kv bulk).
Queues
- One writer per queue; scale with more queues. Producer calls share transactions and durability rounds, each message gets a time-ordered ID, and a broker admits up to 256 concurrent producer calls (committing at most 64 per transaction, four proofs overlapping), refusing more with an error the producer can retry. One queue sustained 7,357 sends per second over a 300,000-send soak with exact delivery. The refusal surfaces as
cell overload: admission refusedin a caught producer error, so a Worker can relay the 503. - One consumer script per queue. A deployment where two scripts consume one queue fails. The consumer script may also export
fetch():examples/queuesexports bothfetchandqueuefrom one script. Lab 2, Part 5 shows what happens when a declared consumer's script loses itsqueue()handler: the local node exits withqueue consumer has no queue handler. Consumer settings follow Cloudflare:max_batch_size10 (max 100),max_batch_timeout5 s (max 60),max_retries3,max_concurrencyup to 250,retry_delay,dead_letter_queuean ordinary queue; celld validates each bound at deploy time, so a bad value fails the deployment rather than the first delivery. A retried message becomes visible again afterdelaySecondsfromretry()orretryAll(), defaulting to the consumer'sretry_delay; celld adds no exponential backoff, so an application that wants one computes the delay frommessage.attempts. Whenattemptspassesmax_retries, celld moves the message to thedead_letter_queue, or deletes it if the consumer names none. Limits: messages up to 128,000 bytes, 100 persendBatch()(256,000 bytes in total),delaySecondsup to 86,400. - Messages. The
contentTypeoption selects"v8","json","text", or"bytes", and thequeues_json_messagescompatibility flag chooses the default, as on Cloudflare. A producer entry'sdelivery_delaysets the default delay for every message sent through that binding. - Messages are retained four days, not configurable. Pull consumers, the Queues HTTP API, dashboard controls, manual consumer attachment, R2 event notifications, and Queue event subscriptions are not available.
- Operate with
celld queue info/peek/purge/pause/resume/redrive.
Workflows
- Replay is the discipline. A running workflow is stored as steps. After a crash,
run()replays from the start, so code outside a step runs again, and a crash after a step side effect can run that step's callback again. The Workflows page does not list this as a celld difference, because it is how Cloudflare Workflows work too. Everything meaningful goes inside a step, and steps must be idempotent. Lab 3, Part 3 counts it: across one durable sleep, the top ofrun()ran twice while each step ran once. - Retention. A successful or failed instance is kept 30 days by default, each duration in the
retentionoption can be at most 30 days, and completed runs can be deleted manually.locationHintaccepts Cloudflare's values but fleet ownership picks the actual location. - Limits. Non-step work cannot stay pending more than 60 seconds; a step result, event payload, and workflow parameters are each capped at 1 MiB. Rollback, sensitive step results, and
ReadableStreamstep results are unavailable. Aworkflowsentry cannot carryschedules,limits, or ascript_namenaming another script. The Workflows REST API andwrangler workflowsdo not operate against celld. - Defaults.
step.do()retries 5 times, 10-second delay, exponential backoff, 10 minutes per attempt, stoppable withNonRetryableError;waitForEvent()times out after 24 hours; an instance within an hour of its next alarm stays resident (CELLD_ALARM_RESIDENT_MS). - Create-once semantics: verify. The page says nothing about
create()with a terminal instance's ID, and nothing aboutpause()/resume()/restart(). Cloudflare refuses the duplicate ID; celld replaced it in v0.4.0 (see Appendix C). By the page's own rule the silence means celld intends to match Cloudflare, but no release note calls it out as a fix. Verify against your installed release if you depend on create-once semantics.
R2
- The R2 binding uses your fleet bucket under
r2/<bucket_name>/, which is how the fleet bucket earns its name. An object'sversionequals its content ETag, so identical content produces the same version. The version comes from the object store: most stores report no version identifier, so the ETag becomes the version.celld dev's local store instead numbers each write from a store-wide counter, so identical bytes under two keys get different versions (Lab 2, Part 4 shows it). Either way, an application must not use a version to count writes, and should not rely on version equality for de-duplication without checking the store it deploys to;checksums.md5is the content hash on both. The five content headers are stored as object headers;customMetadata,cacheExpiry, checksums, and storage class travel together in one JSON value under thecelld-r2user-metadata name (celld_r2on Azure, which refuses a hyphen; #209).celld r2 get|head|put|delete|listoperates these objects without a running node. - Access is through the binding only. There is no public bucket URL, no presigned URL, and no S3 endpoint into an R2 binding, so an application must put a Worker in front of any bytes it wants to publish.
- Interop. An object another tool wrote still reads through the binding: its user metadata becomes its
customMetadataand its headers become itshttpMetadata. Usecelld r2 putto write the complete record.delete(keys)removes up to 1,000 keys in one call. ssecKeyandjurisdictionare not available. A conditional write cannot use a streamed body larger than 8 MiB.- Multipart:
createMultipartUpload()accepts no checksum; a multipart upload cannot resume on another node or after a restart; celld cannot replace a part the store already holds; out-of-order parts are limited to 256 MiB of memory, and completion cannot change the stored part order. - Keys. celld keeps empty key segments, so
a/b,/a/b,a//b, anda/b/are four objects, as in Cloudflare R2. The store percent-encodes keys with non-ASCII or special characters (přehled.htmlbecomesp%C5%99ehled.html) andlist()decodes them, so a listed key is the keyput()received. Butlist()sorts and comparesstartAfterby the encoded form, so a key with one of those characters can land in a different position than on Cloudflare, andstartAftercan skip or include a key Cloudflare would not. A celldcursorhas no such problem. An object v0.5.1 wrote asphotos/stays atphotos.
Wrangler configuration
celld deploy reads wrangler.jsonc or wrangler.json, not wrangler.toml. Supported keys: $schema, name, main, no_bundle, compatibility_date, compatibility_flags, durable_objects, migrations, assets, services, triggers, vars, d1_databases, kv_namespaces, queues, workflows, r2_buckets, worker_loaders, containers, define, and rules. define and rules are both handed to the esbuild run, so neither combines with no_bundle; a rule's type is Text, Data, or CompiledWasm with globs of the form **/*.ext, and a rule that gives **/*.wasm any type but CompiledWasm stops the deploy. The name must be 1–63 lowercase ASCII letters, digits, or internal hyphens. Anything else (routes, unknown keys) stops the deploy with an error naming the key. Compatibility flags (js_rpc, sqlite_vec, websocket_standard_binary_type, delete_all_deletes_alarm, fetcher_no_get_put_delete, and the assets navigation flags) are honored; unmodeled flags are accepted without effect.
{
"$schema": "./node_modules/wrangler/config-schema.json",
"name": "chat",
"main": "src/index.ts",
"compatibility_date": "2026-01-01",
"durable_objects": {
"bindings": [
{ "name": "ROOMS", "class_name": "ChatRoom" }
]
},
"migrations": [
{ "tag": "v1", "new_sqlite_classes": ["ChatRoom"] }
],
"d1_databases": [
{ "binding": "DB", "database_name": "ledger" }
],
"kv_namespaces": [
{ "binding": "SESSIONS", "id": "sessions-prod" }
],
"queues": {
"producers": [ { "binding": "OUTBOX", "queue": "outbox" } ],
"consumers": [ { "queue": "outbox", "max_batch_size": 32 } ]
},
"r2_buckets": [
{ "binding": "FILES", "bucket_name": "files" }
],
"triggers": { "crons": ["*/5 * * * *"] },
"worker_loaders": [ { "binding": "LOADER" } ],
"containers": [
{ "class_name": "Sandbox", "image": "./Dockerfile",
"instance_type": "basic", "max_instances": 4 }
]
}D1 is a cell
A D1 database is just a cell holding one SQLite database, replicated to the fleet bucket, so it inherits the fencing, replication, and durable acknowledgement of any Durable Object. One database has one writer; a fleet gets more capacity from more databases, never from a larger one. Migrations are NNNN_description.sql files in migrations/ (the extension is case-insensitive, and a custom migrations_dir must be a relative path inside the project), applied in numeric order exactly as Wrangler does, in one transaction per file. The celld d1 command runs SQL and migrations against a deployed database, signed with the fleet secret. Import from Cloudflare with wrangler d1 export then celld d1 execute DATABASE --file export.sql; a migration already applied does not run twice when the history arrives with the data. dump(), Time Travel, the D1 REST API, and the wrangler d1 commands do not operate against celld. A SQLite TEXT value that is not valid UTF-8 decodes with U+FFFD, as workerd decodes it; store arbitrary bytes in BLOB.
Cron, alarms, and WebAssembly
Cron triggers run the scheduled handler on celld's own durable alarms, one minute resolution in UTC, exactly once per occurrence fleet-wide. One handler runs at a time per script; a handler can run late but never early. A thrown handler is retried with backoff (starting at 4 seconds, doubling, abandoned after 6 failures, and only the expression that threw), and controller.noRetry() cancels the retry. Note the day-of-week convention: 1 is Sunday, the same as Cloudflare and opposite to most cron dialects. A service-binding target cannot run its own cron triggers.
Wasm imports give the compiled module (Wrangler's rule), uploaded beside the bundle and marked with the wasm-v1 feature so a mixed fleet fails at deploy time. celld compiles each module once per process and reuses it across isolates; worker-build (workers-rs) produces a shim that is a normal celld deploy entry point. Prebuilt deployments work the same way: with no_bundle: true the entry JavaScript ships byte-for-byte and celld applies Wrangler's default **/*.wasm patterns below the entry's directory, so main: "./dist/shim.mjs" importing "./add.wasm" just works, without esbuild on the node.
This chapter draws on: celld: documentation at v0.6.0 (9 entries) · celld: release notes (5 entries) · Cloudflare documentation (4 entries). The full entries are in the Bibliography.