Docs / Serverless Objects / Engine and benchmarks
Native engine and benchmarks
The in-progress native engine: what it supports, bucket backups, durable mode, threads, several nodes, running it as a service, and benchmark results.
The default engine is celld 0.6.2. The native engine is in progress: sobjects dev --engine native runs a Wrangler project's Worker and Durable Objects inside the Node process, with no celld. Each object's storage is its own SQLite file (node:sqlite) under --persist-to, which defaults to <project>/.sobjects/native.
sobjects dev ./my-worker --engine native --port 8787
The native engine is for trusted code. There's no isolate between the app and the engine, and objects get full Node APIs. Use celld for apps you don't control. It needs Node.js 22.16 or newer.
The app's own code is bundled with esbuild, which handles TypeScript and define. npm packages load from node_modules as in any Node program, so packages with native addons (for example better-sqlite3) work. cloudflare:workers and cloudflare:email resolve to the engine's shims.
What works
| Area | Status |
|---|---|
Worker fetch handler, object or WorkerEntrypoint default export |
Yes |
| Durable Object IDs | Yes: idFromName, newUniqueId, idFromString (forged or foreign IDs are rejected), getByName, id.name |
| Stubs | Yes: fetch() and RPC calls with structured-clone arguments, results and errors |
| Input gates | Yes, including blockConcurrencyWhile and async transaction() |
| Storage | Yes: async and synchronous (ctx.storage.kv) KV, ordered list, sql.exec cursors, transactionSync, transaction, deleteAll (also clears the alarm) |
| Alarms | Yes: delivered after a restart, retried with backoff (up to 6 retries) when the handler throws |
vars, cloudflare:workers, cloudflare:email (EmailMessage only) |
Yes |
| WebSockets and the hibernation API | Yes. An evicted object's hibernatable sockets stay open, and the next message wakes a new instance. |
| Agents SDK | Yes: the conformance Agents suite passes |
| KV, D1, R2, Queues, Workflows, service bindings, assets | Not yet. The engine refuses a config that declares them. |
| Idle eviction | Yes: --idle-evict-seconds (default 60; 0 never) |
ctx.waitUntil |
Yes: keeps the object in memory until the promise settles |
| Portability check | Yes: sobjects build [--strict] |
Check portability
sobjects build ./my-worker --strict
build lists each Node.js built-in the app imports, and whether Cloudflare supports it, stubs it, or needs a nodejs_compat flag or compatibility date. It also flags packages with compiled native addons. A portable app runs on Cloudflare, celld and the native engine. A native-only app runs only here. --strict exits 1 for native-only apps.
Bucket backups
export AWS_ACCESS_KEY_ID=... AWS_SECRET_ACCESS_KEY=...
export S3_ENDPOINT=https://<account>.r2.cloudflarestorage.com # omit for AWS S3; set AWS_REGION there
sobjects dev ./my-worker --engine native --backup-bucket s3://my-bucket/node-a
With --backup-bucket, the engine copies each object's SQLite file to <prefix>/native/<script>/<class>/<id>.sqlite within --backup-interval-seconds (default 10) of a change, and again when the object closes or the engine stops. On start, objects in the bucket that have no local file are downloaded and their pending alarms scheduled. Tested against Cloudflare R2 and SeaweedFS.
Limits of interval mode:
- The local file is the source of truth. If the disk is lost, writes since the last upload are lost too.
- Each upload sends the whole file.
- One node per prefix. A node that finds a copy another node wrote stops backing that object up and logs it.
- There is no point-in-time restore in this mode; enable bucket versioning if you need history.
Durable mode
sobjects dev ./my-worker --engine native --backup-bucket s3://my-bucket/node-a --backup-mode durable
In durable mode, nothing the engine acknowledges is lost if the disk is:
| Mechanism | Behavior |
|---|---|
| Output gate | After each event, the engine reads the transactions the object committed from its SQLite WAL and holds the event's result until they are in the bucket. |
| Batching | Transactions from every object on a thread go up together, one gzipped segment per flush. |
| Power loss | Object databases run with synchronous = FULL. |
| Snapshots | Each object gets a full copy every 2,000 transactions or 16 MB of log. Compaction runs every 10 minutes. |
| Point-in-time recovery | Back --backup-retention-days (default 7) with Cloudflare's API. |
| Bucket outage | After 30 seconds, waiting events fail instead of being acknowledged. Their writes ship once the bucket answers. |
Limits:
- A write costs one batched bucket round trip: a few ms to a local MinIO, typically 10 to 40 ms to S3 or R2 in the same region.
- Fetches and WebSocket messages an object sends while its writes are pending aren't held back.
- Writes from timers or
waitUntiloutside an event ship within 200 ms, with no gate. - A node in durable mode doesn't start until it can reach its bucket.
- The store needs conditional writes (
If-None-Match). AWS S3, Cloudflare R2, SeaweedFS and MinIO from RELEASE.2025-09-07 support them. Backblaze B2, Garage and DigitalOcean Spaces don't. The node checks this at start.
Point-in-time recovery
const bookmark = await this.ctx.storage.getBookmarkForTime(Date.now() - 60 * 60 * 1000);
const undo = await this.ctx.storage.onNextSessionRestoreBookmark(bookmark);
this.ctx.abort(); // the next request sees the object as it was an hour ago
getCurrentBookmark() and getBookmarkForTime(time) return bookmarks. onNextSessionRestoreBookmark(bookmark) restores on the object's next start and returns a bookmark that undoes the restore. A bookmark the bucket can no longer rebuild, or a time before the kept history, is refused with a RangeError. Without durable mode, these methods throw.
Worker threads
sobjects dev ./my-worker --engine native --threads 4
Each Durable Object lives on exactly one thread, chosen from its ID. A thread that doesn't answer for --thread-stall-seconds (default 30), throws an uncaught error, or exceeds --thread-memory-mb is replaced, and its objects reload from their SQLite files.
- Threads are not a security boundary.
- A call that crosses threads costs roughly 0.3 to 0.5 ms more.
- In one test, 16 CPU-bound objects went from 89 req/s on one thread to 169 req/s on 4 and 182 on 8 (M1 Pro).
Several nodes
# on each node, with a unique, stable name and the same secret
export SOBJECTS_CLUSTER_SECRET=... # 32+ characters, identical on every node
sobjects dev ./my-worker --engine native --backup-bucket s3://my-bucket/app --backup-mode durable \
--node-name node-a --peer-listen 10.0.0.5:9790
With --node-name, nodes in durable mode share one bucket prefix and each object runs on one node at a time.
| Topic | Behavior |
|---|---|
| Leases | Each node renews its lease every third of --lease-seconds (default 10). A node that can't renew stops acknowledging writes and exits. |
| Takeover | When an object's owner released it or its lease ran out, another node claims it and rebuilds it from the bucket. |
| Alarms | Every node checks for due alarms whose owner is gone and claims those objects. |
| Draining | On SIGINT or SIGTERM a node drains for up to --drain-seconds (default 30), closes client WebSockets with 1001 and releases its objects. |
| Forwarding | With --peer-listen, a call for an object another node runs is forwarded to that node. |
| Peer security | Peer requests are signed with SOBJECTS_CLUSTER_SECRET (HMAC-SHA256) but not encrypted. Bind the peer listener to a private network, never the public internet or the ingress. |
Not yet: forwarding without --peer-listen, TLS between nodes, --node-name combined with --threads, and cross-node compaction.
Running as a service
sobjects up runs the native engine when sobjects.toml has a [native] table in place of [celld]:
[native]
project = "./my-worker" # the Wrangler project to run; relative to this file
listen = "127.0.0.1:8787" # default
# data_dir = "/var/lib/serverless-objects/native" # default: $SOBJECTS_DATA_DIR/native
# idle_evict_seconds = 60
# drain_seconds = 30
# threads = 2
[native.backup]
bucket = "s3://my-bucket/app"
mode = "durable" # default here; "interval" is the other choice
# retention_days = 7
# endpoint = "https://<account>.r2.cloudflarestorage.com"
# region = "auto"
# [native.cluster]
# node_name = "node-a"
# lease_seconds = 10
# peer_listen = "10.0.0.5:9790"
# peer_advertise = "http://10.0.0.5:9790"
Credentials and SOBJECTS_CLUSTER_SECRET come from the environment, never the file. For a systemd unit, put them in sobjects.env beside the config.
--detach,status,logsanddownwork as with celld.downwaits up to a minute for the engine to upload what's left.sobjects service installwrites a unit that restarts the node if it exits, including after it loses its lease.[ingress]routes to[native].listenby default.[admin]and the dashboard aren't available with[native]yet.sobjects deployis for celld: restart the node to run new code.
Without [native.backup], objects exist only in data_dir. The full key list is in the CLI reference.
Benchmarks
packages/conformance/bench-app is one Worker with one Durable Object class. The same code runs on Cloudflare, Wrangler, celld and the native engine.
npm run bench --workspace=sobjects-conformance -- --target native # or celld, wrangler
npm run bench --workspace=sobjects-conformance -- --url https://sobjects-bench.<you>.workers.dev
Durability differs by target
Writes are not equally durable across targets, and that dominates the write rows:
| Target | When a write is acknowledged |
|---|---|
| Cloudflare | After storage replicated across machines confirms it (the output gate). |
| celld | After the node has proven the write durable. A single node needs bucket proof. |
| Native engine, default | After a local SQLite WAL commit with synchronous = NORMAL. Survives a process crash, not power or disk loss. |
| Wrangler | wrangler dev --local, a development server with local SQLite. |
Results, 20 Oct 2026
Client: Apple M1 Pro, Node.js 24.12. Local targets on loopback: serverless-objects 0.5.0 native engine, celld 0.6.2 (celld dev), Wrangler 4.150.0. Cloudflare: the same app on a workers.dev subdomain on the free plan, served from Frankfurt, about 130 to 200 ms from the client. One run each; treat differences under about 20% as noise.
Inside the Worker, mean per call (500 sequential calls to one object, 100 for SQL):
| Mean per call, ms | Cloudflare | native | celld | Wrangler |
|---|---|---|---|---|
| DO read (RPC + KV get) | 10.89 | 0.03 | 0.07 | 0.39 |
| DO write (RPC + KV put) | 19.32 | 0.05 | 9.29 | 0.68 |
| 100-row SQL transaction | 39.08 | 0.35 | 9.90 | 4.86 |
From the client:
| p50 / p99 ms | Cloudflare | native | celld | Wrangler |
|---|---|---|---|---|
| noop (Worker only) | 202 / 571 | 0.28 / 1.11 | 0.32 / 3.39 | 1.32 / 2.72 |
| DO read | 222 / 482 | 0.25 / 0.91 | 0.41 / 1.11 | 1.60 / 7.27 |
| DO write | 301 / 744 | 0.22 / 1.09 | 8.70 / 24.39 | 1.68 / 3.56 |
| 100-row SQL transaction | 253 / 740 | 0.51 / 1.20 | 9.48 / 21.30 | 4.69 / 21.94 |
| WebSocket echo (hibernatable) | 151 / 373 | 0.08 / 0.17 | 0.13 / 0.19 | 0.30 / 0.89 |
| Writes, 32 in flight, req/s (p99 ms) | Cloudflare | native | celld | Wrangler |
|---|---|---|---|---|
| 1 object | 136 (871)* | 4,507 (44.0) | 1,705 (39.5) | 1,437 (48.4) |
| 32 objects | 153 (623)* | 9,721 (9.9) | 178 (2,780) | 1,207 (92.4) |
* Limited by the client: 32 requests in flight over a roughly 200 ms round trip caps out near 160 req/s. These rows say nothing about Cloudflare's capacity.
Native engine in durable mode, against a local MinIO, 32 requests in flight spread over N objects:
| Objects | 1 | 2 | 4 | 8 | 16 | 32 |
|---|---|---|---|---|---|---|
| writes/s | 1,199 | 1,442 | 1,367 | 1,593 | 1,376 | 1,207 |
| p50 ms | 24.8 | 21.5 | 21.9 | 18.8 | 22.3 | 21.7 |
Reading the results
- On one machine, the native engine and celld have no network hop to the object, which is where most of their advantage over Cloudflare comes from. It is the trade-off of running on one node, not a faster implementation of the same guarantees.
- celld's single-node write throughput falls as more objects write at once (from about 1,800 to 240 writes/s under
celld servewith SeaweedFS). - The native engine in its default mode is the fastest target and its writes are the least durable. Choose it when the app is trusted and a few seconds of writes on one machine are an acceptable loss. Choose Cloudflare or celld when every acknowledged write must survive the machine. Durable mode closes this gap for the native engine at the cost shown in its table.
Full tables and methodology: benchmarks source and native engine source.