moxzi
Docs / Actor platforms / Cloudflare Durable Objects

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#

OwnsWhoWhat
IdentityMoxziActor Durable ObjectidFromName("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
Routingthe Worker (stateless), beside the objectsevery 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 timersMoxziActor's alarmstorage.setAlarm() when a turn asks for one, alarm() runs POST {service}/v2/remind and re-arms from the answer
Placement / held / pending callsMoxziControl's storageone control object holds placement, held:* and route:* keys; a DO's storage is durable by construction
Motoko execution, the heap, upgradesmoxzi-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:

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.

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
Bindingthe contract engine (moxzi-web) inside each Durable Object
The actor's image lives inDurable Object storage
Update latency vs Cloudflare's own durable actor (p50, macOS)1.76x
ConformanceHOST=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 scenarioCloudflare
PersistencePASS
Process FailurePASS
Trap RollbackPASS
Actor IsolationPASS
Actor ParallelismPASS 1.07×
Duplicate InvocationPASS
AwaitPASS
Await Across RestartPASS
Upgrade With No AwaitsPASS
Upgrade With Outstanding AwaitPASS
Permanently Unresolved AwaitPASS
Timer Across PassivationPASS
Timer Across Process FailurePASS
Tiny Heap Mutation (206 B on an 8 MB heap)PASS
ICRC-72PASS
Upgrade CompatibilityPASS
total16/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.

On this pageWhat Cloudflare owns, what Motoko ownsPrerequisitesWalk through itWrite and build the two actorsStart the moxzi-actor-host servicesStart the WorkerPoint placement at the services, and call itA cross-actor awaitArm a timerKill it and bring it backOperating notesIn processThe conformance matrix