Cloudflare Durable Objects
A Worker with two Durable Object classes: MoxziActor (one object per key Type/id, idFromName) and MoxziControl (one object, the host's own durable state). The code is moxzi/adapters/cloudflare — moxzi-runtime embeds wasmtime (a JIT), which the Workers runtime cannot host, so the Durable Object is a proxy to a moxzi-actor-host service and never holds the Motoko heap itself.
What Cloudflare owns, what Motoko owns#
| Owns | Who | What |
|---|---|---|
| Identity | MoxziActor Durable Object | idFromName("Type/id"); a DO's own single-threaded execution per object is the one-step-at-a-time guarantee, chained through a Promise queue inside the object so concurrent fetches to one key still serialize |
| Routing | the Worker (stateless), beside the objects | every parked await becomes a route row in MoxziControl's storage before it is routed; the callee's turn runs through its own MoxziActor; the answer resumes the caller; a restart re-drives every non-held route |
| Durable timers | MoxziActor's alarm | storage.setAlarm() when a turn asks for one, alarm() runs POST {service}/v2/remind and re-arms from the answer |
| Placement / held / pending calls | MoxziControl's storage | one control object holds placement, held:* and route:* keys; a DO's storage is durable by construction |
| Motoko execution, the heap, upgrades | moxzi-actor-host (Rust) | everything past /v2/* — a MoxziActor's fetch does exactly one JSON POST per step |
Routing lives in the Worker, not inside MoxziActor.fetch, for the same reason as every other host here: a request that awaited a routed call inside itself would hold the key for the whole await. A Durable Object's input gates only close during its own storage operations, so the service's call-backs into the same object during a turn (arming the alarm, saving the route) do not deadlock against the object's own in-flight fetch.
Prerequisites#
Verified on this machine:
- Node
v25.2.1. wrangler 4.137.0(installed as a dev dependency —npm installinmoxzi/adapters/cloudflarealready did this;node_modules/.bin/wrangler --versionto check).moxziandmoxzi-actor-host, built (cargo build --release -p moxzi -p moxzi-actor-hostundermoxzi/, or use the ones already atmoxzi/target/release/).- No Docker and no Postgres for this one —
wrangler dev --localruns the real Workers runtime (workerd) locally, and Durable Object storage is on disk under--persist-to.
Walk through it#
Write and build the two actors#
Same two actors as the other host pages — Customer awaits Bank on chargeVia; both are simplified from moxzi/conformance/fixtures/{customer,bank}.mo:
// customer.mo
import Prim "mo:⛔";
persistent actor {
type Order = { id : Nat; total : Nat };
var balance : Nat = 0;
var orders : [Order] = [];
var timerFired : Nat = 0;
public func addOrder(order : Order) : async Nat {
orders := Prim.Array_tabulate<Order>(orders.size() + 1, func (i : Nat) : Order {
if (i < orders.size()) orders[i] else order
});
balance += order.total;
orders.size()
};
public query func getBalance() : async Nat { balance };
public func chargeVia(bank : Text, amount : Nat) : async Nat {
let b : actor { debit : (Nat) -> async Nat } = actor (bank);
let v = await b.debit(amount);
balance += v;
balance
};
public func armTimer(delayNs : Nat64) : async () {
ignore Prim.setTimer<system>(delayNs, false, func () : async () { timerFired += 1 });
};
public query func timerCount() : async Nat { timerFired };
};
// bank.mo
persistent actor {
var debited : Nat = 0;
public func debit(n : Nat) : async Nat { debited += n; n };
public query func total() : async Nat { debited };
};
MOXZI_COMPILER=bin/compiler-canister/compiler.wasm MOXZI_LINKER=bin/linker-canister/linker.wasm \
moxzi/target/release/moxzi build customer.mo -o customer.wasm
MOXZI_COMPILER=bin/compiler-canister/compiler.wasm MOXZI_LINKER=bin/linker-canister/linker.wasm \
moxzi/target/release/moxzi build bank.mo -o bank.wasm
Start the moxzi-actor-host services#
moxzi/target/release/moxzi-actor-host --listen 127.0.0.1:3021 --state ./s1 \
--namespace demo --deployment crm --actor Customer=./customer.wasm --known Bank/1
# moxzi-actor-host: Bank/1 is k2e4o-mgnlm-is6i5-r7owd-3wgr3-b4lrw-vi2fq-vf6zl-43mw6-2jicp-kae
# moxzi-actor-host: moxzi-actor-host/2 on 127.0.0.1:3021, hosting Customer
moxzi/target/release/moxzi-actor-host --listen 127.0.0.1:3022 --state ./s2 \
--namespace demo --deployment crm --actor Bank=./bank.wasm
Start the Worker#
cd moxzi/adapters/cloudflare
npm install # once
node_modules/.bin/wrangler dev --local --port 8787 --ip 127.0.0.1 \
--persist-to ./wrangler-state --show-interactive-dev-session=false
# ⛅️ wrangler 4.137.0
# Your Worker has access to the following bindings:
# Binding Resource Mode
# env.MOXZI_ACTOR (MoxziActor) Durable Object local
# env.MOXZI_CONTROL (MoxziControl) Durable Object local
# [wrangler:info] Ready on http://127.0.0.1:8787
wrangler dev --local really runs workerd — the same runtime Cloudflare's edge runs — as a child process; you will see it in ps alongside the wrangler/Node process.
Point placement at the services, and call it#
curl -X PUT localhost:8787/_moxzi/placement \
-d '{"types":{"Customer":["http://127.0.0.1:3021"],"Bank":["http://127.0.0.1:3022"]}}'
# HTTP/1.1 204 No Content
curl -X POST localhost:8787/actors/Customer/1/method/addOrder \
-H 'content-type: application/json' -d '[{"id":1,"total":30}]'
# HTTP/1.1 200 OK, x-moxzi-outcome: replied
# [1]
A cross-actor await#
curl -X POST localhost:8787/actors/Customer/1/method/chargeVia \
-H 'content-type: application/json' \
-d '["k2e4o-mgnlm-is6i5-r7owd-3wgr3-b4lrw-vi2fq-vf6zl-43mw6-2jicp-kae",10]'
# [40]
curl -X POST localhost:8787/actors/Bank/1/method/total -H 'content-type: application/json' -d '[]'
# [10]
Customer's MoxziActor object parked; the Worker journaled the route in MoxziControl, ran Bank's turn on the service that owns it, and resumed Customer with the reply — getBalance now reads 30 + 10 = 40.
Arm a timer#
curl -X POST localhost:8787/actors/Customer/1/method/armTimer \
-H 'content-type: application/json' -d '[2000000000]'
sleep 3
curl -X POST localhost:8787/actors/Customer/1/method/timerCount -H 'content-type: application/json' -d '[]'
# [1]
That count came from a real Durable Object alarm: armTimer's step told the Worker to setAlarm, and alarm() called POST {service}/v2/remind when it fired.
Kill it and bring it back#
# stop the wrangler/workerd processes (wrangler dev starts a `workerd` child — stop it too,
# `pkill -f workerd` alongside the wrangler process, or Ctrl-C the whole process group)
pkill -f 'wrangler dev --local --port 8787'
pkill -f 'moxzi-actor-host --listen 127.0.0.1:302'
# … restart wrangler dev with the SAME --persist-to, and both moxzi-actor-host services on
# the SAME --state dirs …
curl -X POST localhost:8787/actors/Customer/1/method/getBalance -H 'content-type: application/json' -d '[]'
# [40] — placement came back from MoxziControl's Durable Object storage, no re-PUT needed
curl -X POST localhost:8787/actors/Customer/1/method/timerCount -H 'content-type: application/json' -d '[]'
# [1]
Operating notes#
These notes are for the service form. In process, the actor's image lives in Durable Object storage and nothing is written to a service's disk — see In process below.
- Durable state: placement, held keys and the route journal live in
MoxziControl's Durable Object storage; eachMoxziActorobject stores its own key's armed-alarm record. Locally,--persist-to <dir>is whereworkerdkeeps that storage on disk — keep it to keep state acrosswrangler devrestarts, as above. The Motoko heap itself is never a DO value: it lives on eachmoxzi-actor-hostservice's own disk (a snapshot, a WAL tail, and a content-addressedmodules/directory of deployed wasm). - Upgrading an actor: drain, then upgrade with the new module as base64 — routed through the object, exactly like a method call: ``
sh curl -X POST localhost:8787/actors/Customer/1/admin/drain -d '{}' # {"state":"Quiescent"} curl -X POST localhost:8787/actors/Customer/1/admin/upgrade \ -H 'content-type: application/json' \ -d "{\"deploymentId\":\"crm2\",\"wasm\":\"$(base64 -i customer.wasm)\"}" # {"state":"Active"}`GET …/admin/{state,outstanding}` bypasses the object and asks the owning service directly. - What a real
wrangler deployneeds, beyond this: a Cloudflare account with Durable Objects enabled, an API token (wrangler loginorCLOUDFLARE_API_TOKEN+account_idinwrangler.toml), and — the part that is easy to miss — a publicly reachablemoxzi-actor-hostservice for the deployed Worker's placement to point at: Cloudflare's edge cannot reach a127.0.0.1service the way localworkerdcan. That means runningmoxzi-actor-hostsomewhere with a real address (a VM, a container platform) andPUT-ing/_moxzi/placementwith its public URL once the Worker is live. None of that was run here — this walk-through only exerciseswrangler dev --local. - What to watch:
wrangler dev's own log for the Worker'sconsole.log/thrown errors (the route handlers return502with the stack on an uncaught exception), and eachmoxzi-actor-hostservice's stderr for its own turns.
In process#
The same actor also runs inside Cloudflare's own process through moxzi-rts, with Cloudflare keeping its image: see /docs/hosts/in-process/ for the contract.
| In process on Cloudflare | |
|---|---|
| Binding | the contract engine (moxzi-web) inside each Durable Object |
| The actor's image lives in | Durable Object storage |
| Update latency vs Cloudflare's own durable actor (p50, macOS) | 1.76x |
| Conformance | HOST=cloudflare-inproc scripts/host_matrix_gate.sh |
Workers cannot compile wasm at run time, so build-native.sh bundles every actor module, prepared ahead, keyed by its SHA-256. Compile actors with --compact-rts here: 2 MB GC partitions keep the wasm memory length near what the heap uses, inside a 128 MB isolate.
From moxzi/adapters/cloudflare/ (the exact commands and environment are in moxzi/conformance/hosts/cloudflare-inproc.json):
bash build-native.sh <directory of actor .wasm files>
node_modules/.bin/wrangler dev --config wrangler-native.toml --local --port 8787
The conformance matrix#
Cloudflare's column of the spec §61 matrix, real host, real process kills (docs/host-adapters.md, measured 2026-09-23 against compiler b7577666):
| §61 scenario | Cloudflare |
|---|---|
| Persistence | PASS |
| Process Failure | PASS |
| Trap Rollback | PASS |
| Actor Isolation | PASS |
| Actor Parallelism | PASS 1.07× |
| Duplicate Invocation | PASS |
| Await | PASS |
| Await Across Restart | PASS |
| Upgrade With No Awaits | PASS |
| Upgrade With Outstanding Await | PASS |
| Permanently Unresolved Await | PASS |
| Timer Across Passivation | PASS |
| Timer Across Process Failure | PASS |
| Tiny Heap Mutation (206 B on an 8 MB heap) | PASS |
| ICRC-72 | PASS |
| Upgrade Compatibility | PASS |
| total | 16/16 |
Reproduce it (starts wrangler's local workerd and two moxzi-actor-host instances per actor type, and drives all sixteen scenarios with real kills and restarts):
HOST=cloudflare scripts/host_matrix_gate.sh
This is local evidence only — the real Cloudflare network, not workerd locally — is not run by this gate; see the deploy note above for what stands between here and that.