Browser limitsalpha
What can a tab not do that native can?
Every row below is a decision or a measurement, not an oversight, and each names the source you can check it against. The single constraint underneath most of them: a browser tab is not a replica and cannot be made to look like one without lying, so where a capability is missing it is missing rather than simulated.
The table#
| Limit | Why it is this way | What it means in practice | Check |
|---|---|---|---|
| No certificates | A certificate is a subnet threshold signature. There is no subnet and no key, so one could only be forged — worse than none, because a verifier would believe it. | ic0.data_certificate_present answers 0 (the same answer an update gets on the IC); data_certificate_size is 0. @dfinity/agent's Actor cannot be used; certified-data patterns need the real IC. | moxzi/web/src/lib.rs |
| Per-instruction metering is opt-in | V8 offers no fuel, so the module has to carry its own counter. moxzi-web instruments it at install time; that is a real cost, so it is a decision rather than a default. | meter: true at start or per install. Metered, an instruction limit is enforced exactly as moxzid and the IC enforce one. Unmetered, a tight loop runs until wall-clock stops it. | moxzi/runtime/src/meter.rs, scripts/web_meter_gate.sh |
| Wall-clock preemption is worker-only, and coarse | A page cannot interrupt a running wasm call, and a terminated worker cannot say where it got to. | deadlineMs requires worker: true. Rewind is to the last completed message, so it discards committed work belonging to actors that had nothing to do with the runaway. meter: true is the finer tool: the message traps itself and only its own work is lost, in a page as well as a worker. | moxzi/web/lib/moxzi.js, scripts/web_meter_gate.sh |
| The rewind is coarser than the IC's | A terminated worker cannot report where it got to. | A trap on the IC discards exactly its own writes; here the rewind is to the last message that completed. What the runaway itself wrote is gone either way; a rewind crossing an earlier commit point would not be. autosave: true makes the window one message wide. | moxzi/web/lib/moxzi.js (recover) |
| Recovery skips restored-only actors | An actor that arrived through loadAll has no module bytes on the page's side — only the runtime blob had them — so the page cannot rebuild it after a kill. | Post-kill restore covers actors the page installed. For spawned actors, keep your own saveAll checkpoint and loadAll it, as the knights demo does. | moxzi/web/lib/moxzi.js (recover) |
| HTTPS outcalls need a worker and cross-origin isolation | fetch is asynchronous and the drain is not, so the runtime blocks on Atomics.wait — forbidden on the main thread — while the page performs the request through a SharedArrayBuffer. | http: true requires worker: true plus Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp. start refuses immediately if the page is not isolated. | moxzi/web/lib/moxzi.js, scripts/web_outcall_gate.sh |
| An outcall response must fit the shared channel | The response is copied through a fixed SharedArrayBuffer. | The channel is 4 MiB and is not configurable through Moxzi.start; a larger response fails with "response too large for the channel". | moxzi/web/lib/moxzi.js (sabBytes) |
raw_rand is derived, not drawn | The runtime replays a message log to recover, so every external input must be reproducible. | Randomness comes from a fixed default seed ([7; 32]) plus a per-caller counter, so two fresh pages produce the same sequence. setSeed exists on the raw WebRuntime but is not exposed through the client library. | moxzi/web/src/lib.rs |
| Deep expression nesting compiles less far than natively | V8 hosts the guest's stack as well as its own, and a guest frame costs far more there than on wasmtime. | Measured: ~60 chained operators compile in a tab; past ~400 terms the limit moves into parsing. Hand-written Motoko does not approach this; machine-generated code should chunk expressions or build with the CLI. | docs/limits.md, scripts/web_page_compile_gate.sh |
| A worker's stack is smaller than a page's | Browser default, not a setting a page can change. | ~11,154 JS-frame-equivalents on the main thread versus ~5,303 in a worker. The compiler needs ~8,426, so it runs on the page thread; moving it into a worker makes things worse, not better. | scripts/web_page_compile_gate.sh |
| Many live actors exhaust V8's memory | Each program keeps a memory64 instance alive for its run. | Past roughly a hundred simultaneous instances in one isolate, memory.grow fails and the guest traps with a bare unreachable. The corpus gate runs in batches of 30, each its own process, for this reason. | scripts/web_corpus_gate.sh |
| The runtime is closed | There is no agent and no boundary node in a tab. | Actors can call actors in the same runtime and can make HTTPS outcalls; they cannot call a canister deployed on the IC. Two tabs are two independent runtimes. | moxzi/web/src/lib.rs |
| Nothing coordinates two tabs over one store | indexedDbStore is a plain {get,set,delete,keys} wrapper with no locking. | Two tabs persisting the same actor to the same database will overwrite each other. Use a different store name per tab, or persist from one. | moxzi/web/lib/storage.js |
| Timers only fire when something drives them | The runtime does not own a clock loop. | Pass timerMs to Moxzi.start, or call moxzi.tick() yourself. Without either, due timers never run. | moxzi/web/lib/moxzi.js |
| Cycles are accounted, not economic | There is no subnet charging rent. | Creation and message fees are charged and observable; idle_cycles_burned_per_day is 0 and nothing is burned while idle. | moxzi/web/src/lib.rs |
| The differential sweep does not cover every corpus program | Programs the browser cannot host are named rather than counted as agreement. | 149 programs compared, 0 diverging. One (region0-rts-stats) runs on neither host — it asserts the replica's exact physical stable-memory size at canister_init — and is named rather than counted as agreement. Programs that create canisters are counted in their own bucket by the harness. | scripts/web_corpus_gate.sh |
Two things that are not limits#
Persistence is real. Snapshots use the same layout the native host writes, so a snapshot means the same thing in a page, in moxzid, and in a file. Whole-runtime save/restore brings back spawned actors, whose code never crossed into the page.
Compilation is exact. The in-tab compiler's output is byte-identical to a native moxzi build, including programs with mo: package imports resolved through fetchMopsClosure. This is a byte comparison in the gates, not an assertion that it produced something.
What would change these#
The stack limits are the only rows with a known fix rather than a reason: moving codegen's expression descent onto an explicit heap work-stack — the same change the on-chain campaign already made for the same cause — would stop native frame depth from tracking the input's nesting depth. A frame diet alone buys the compiler headroom and leaves the nesting ceiling where it is.
The rest are structural. Certificates need a subnet, and no amount of instrumentation substitutes for one.
Metering and preemption used to be on that list and are not any more: a module that counts its own instructions needs nothing from the engine, so the browser now traps one runaway message and leaves every other actor alone — the thing terminating a worker could never do.
Next#
- /docs/web/overview/ — what the tab does run, and the evidence for it.
- /docs/web/api/ —
deadlineMs,recover,saveAlland the calls these limits shape. - /docs/web/install/ — COOP/COEP headers and the worker/bundler rule.