Running cells in a browser
A browser has no server process, no node:sqlite and no way for a ServiceWorker to accept a
WebSocket. CellB still runs a project’s Workers and Durable Objects there: SQLite WASM on OPFS in a
Dedicated Worker, requests through a ServiceWorker, WebSockets over MessagePorts. This page covers
the four ways to wire it, the S3 setup for a fleet bucket, what fails, and what differs in Firefox
and WebKit.
What a browser forces
- Synchronous SQLite needs a Dedicated Worker. OPFS synchronous access handles exist only there.
sahpoolOpener(the opfs-sahpool VFS) needs no COOP/COEP headers, so any static host works. - An opfs-sahpool pool is exclusive. A second worker opening the same pool name fails with
NoModificationAllowedError. Storage is therefore partitioned: one pool per worker. - An opfs-sahpool pool has a fixed number of files until it grows, and growing is asynchronous
while opening a database is not.
sahpoolOpenergives the runtimeroom(cellKey): a cell’s first event waits for room (about three files per cell: the database and its journal), and the pool grows in the background whenever less than half of it is free. - One worker’s cells share one SQLite heap of at most 2 GiB, which never shrinks. Each open
cell takes about 120 KiB of it (a connection without lookaside, 4 KiB pages for a new
database), so about 20,000 small cells can be open in one worker (measured in Chromium:
browser-capacity.md). Memory of a closed database
is reused by the next one opened (
closeDatabaseAfterMscloses idle cells’ databases), so the cells a worker keeps on disk are not limited by the heap. A worker that ends gives its heap back. - One worker is one thread. Its cells interleave; their SQLite I/O runs on that thread. For parallel work, spread the project over several workers (next section).
- Journals stay in the pool.
sahpoolOpeneropens databases withjournal_mode=PERSIST: the rollback journal keeps its pool slot between commits instead of being created and deleted on each one. The database file is the same as with the defaultDELETE(only WAL marks it). AsyncLocalStorageneeds help. Browsers have no async context yet: for a nodejs_compat project,browserProjectRuntimemakes promise and timer continuations carry their stores, and the code’sawaitis lowered so that it goes through them: bybundleWorkerForBrowserin a bundle, bycreateProjectSitein a site’s modules (gaps.md). A browser with TC39 AsyncContext needs neither:node:async_hooksthen runs on it, and a site lowers nothing.- A ServiceWorker cannot spawn workers and is stopped when idle, dropping every port it held. It can route requests, hold Web Locks and pass MessagePorts, so it routes and a page owns the workers.
- A ServiceWorker cannot intercept WebSockets. Pages and objects get a WebSocket class whose frames travel over MessagePorts (ADR 0006).
- Without an open page nothing runs. No page means no Dedicated Worker and no SQLite. Cells that must stay reachable live on a fleet bucket that server nodes share.
Four topologies
A project in one worker: browserProjectRuntime
page ── fetch("/app/…") ──► ServiceWorker (webrun-http-browser)
│ forwards to the page's handler
▼
Dedicated Worker: browserProjectRuntime({ config, code, sqlite3, pool, filesDir, bucket? })
cells: SQLite WASM in OPFS pool `pool` · files: OPFS `filesDir`
loaded Workers: one Dedicated Worker each (isolateScript)
bucket: a celld fleet bucket (S3, OPFS or any ObjectStore)
browserProjectRuntime installs the Workers globals (Response that accepts status 101, a
Request whose copies keep an upgrade’s headers, WebSocket, WebSocketPair, caches,
HTMLRewriter, and Buffer/process for nodejs_compat), imports the
code and builds the runtime from the wrangler configuration. The code is either:
{ bundle }— one ES module built for browsers, whosecloudflare:workersimport and Workers globals read what the runtime installs.bundleWorkerForBrowser(entry, { nodeCompat })from@statewalker/cellb/nodebuilds it:cloudflare:workerswith the project’s ownenvandexports,cloudflare:email, and with nodejs_compat thenode:*polyfills (bare names such as"path"too). A Flue app built for Cloudflare runs this way (examples/flue); or{ site }— the base URL of an in-browser HTTP server made bycreateProjectSiteand hosted by@statewalker/webrun-site-host, which compiles the project’s sources on request (@statewalker/webrun-modules). Loaded Workers’ code is uploaded to it and imported from URLs. The project’scloudflare:workershas its ownenvandexports(Workers loaded into its realm get empty ones; in isolates, their own), andcloudflare:emailloads. WithnodeCompatthe site lowersawaitin the modules it serves, soAsyncLocalStorageanswers as in a bundle; the site’snodeCompatmust match the project’s flag (see below).
A loaded Worker’s cloudflare:workers has its own env and exports (its WorkerCode.env, and
stubs of its own entrypoints), except on a site without isolateScript.
With isolateScript, each loaded Worker runs in its own Dedicated Worker; without it, it runs in
this worker’s realm. The package ships that worker as @statewalker/cellb/isolate-worker; with
Vite, import isolateWorker from "@statewalker/cellb/isolate-worker?worker&url" and pass
isolateScript: new URL(isolateWorker, import.meta.url).
The host app (e2e-browser/pages/host; pnpm browser:dev, then /host/) is this topology: pick a
project folder, a node name and a bucket, and the project is served at its own URL.
A project over several workers: startProjectShards
page: startProjectShards({ shards: 4, spawn, config, code, name }) → { fetch, shards, close }
│ ingress, round robin
▼
worker i: serveProjectShard({ sqlite3 })
browserProjectRuntime(…, pool `<name>-i`, route)
owns the cells with shardOf(id, 4) === i; other cells → worker shardOf(id) over a port
Each worker hosts the whole project with its own OPFS pool and owns the cells shardOf gives
it; a Durable Object call from any worker runs in the owner’s worker, so cells run in parallel
threads. Any worker takes ingress (the Worker’s fetch), and fetch spreads requests over them.
The worker script is one line:
import sqlite3InitModule from "@sqlite.org/sqlite-wasm";
import { serveProjectShard } from "@statewalker/cellb/browser";
serveProjectShard({ sqlite3: () => sqlite3InitModule({ print() {}, printErr() {} }) });
- Placement is fixed by the cell id: a few dozen cells spread unevenly (hashing), and nothing rebalances them.
- The workers belong to the page: closing it stops them. To share them between tabs, use the next topology.
browserProjectRuntime({ route })is the building block: any placement that maps a cell to the worker hosting it works the same way.
A project shared by the tabs: joinProjectShards and installProjectRouter
ServiceWorker: installProjectRouter(self, { basePath: "/app/" })
/app/<name>/… ─► the project's CellRouter ─► the live shard with the fewest requests in flight
shard → shard ─► the shard the sender named (x-cellb-shard)
every tab: joinProjectShards({ name, shards: 4, spawn, config, code, serviceWorker })
presence lock cellb:<name>:tab:<joined-at>-<uuid>
shard i → tab (i mod n) of the n tabs present, oldest first; one Web Lock per shard
shard worker: serveProjectShard({ sqlite3 }) — pool <name>-shard-i, linked to the router
Every tab of the origin sends requests to <basePath><name>/…; the ServiceWorker passes them,
with their URL unchanged (as a Worker on a Cloudflare route sees them; stripBase: true gives
the Worker /… instead), to a shard worker, wherever it runs. The tabs split the shards evenly:
each computes the same owners from the tabs present and takes its own, so a project of 4 shards
runs 4 / 0 in one tab, 2 / 2 in two, 2 / 1 / 1 in three — more tabs, more processes, more cores.
- A tab joins or leaves (
close()): the shards whose owner changes are handed over. The router stops sending the shard new requests (they wait for the next owner), lets the ones in flight finish for up todrainMs(5 s), then the old worker closes its runtime and gives up its OPFS pool, and the next owner opens the same pool: the cells move with the shard. A long-poll or SSE stream still open keeps the drain waiting untildrainMs. Before it closes, the old worker closes its sockets with1001(going away), so their clients reconnect to the next owner at once instead of noticing through the heartbeat. - Pages open sockets with
createWebSocketClass(serviceWorkerConnector(serviceWorker)): the ServiceWorker routes the upgrade to a shard worker and hands the page the socket’s port, so the socket then runs page ↔ shard worker, without the ServiceWorker (it survives a restart of it). - A tab closes or crashes: it runs no code. Its locks are released, the router sees each shard
worker’s own lock go and fails the requests in flight there (
ShardOwnerLostError), and the other tabs take the shards over withincheckMs(1 s). - The ServiceWorker restarts: it asks the tabs for their shards’ links again. The browser stops
an idle ServiceWorker, and its ports die without telling anyone. A request to it starts it
again, but a call that begins in a shard (an alarm, a timer, a WebSocket message) to an object
in another shard does not. So the router holds a Web Lock (
cellb:router:<uuid>) while it lives, and each shard watches it. When the lock is released, the shard drops its links, and its next call into another shard waits: the shard asks its tab to post a message to the ServiceWorker, which starts it again, and that call goes through once the links are back. A call already in flight when the ServiceWorker stopped has lost its answer: it fails (“The router was lost while this call was in flight”), and may or may not have been applied. To make that rare, a shard with calls into other shards in flight keeps the ServiceWorker up: every 20 s its tab posts a message to it, since those calls travel over MessagePorts and are no events to it (Chromium stops a ServiceWorker after about 30 s without events). The ServiceWorker can still stop (an update, a crash, memory pressure), so the failure stays. - Tests of the idle timeout need a Chromium without Playwright’s DevTools sessions: Playwright
attaches one to every ServiceWorker, which keeps it running.
e2e-browser/raw-chromium.tsdrives a separate Chromium over CDP, attached to its pages only. - Placement inside the project is still by cell id (
shardOf): a few dozen cells spread unevenly. - With
bucket, each shard is a fleet node (<node>-s<i>); use S3, as an OPFS bucket has one writer at a time.
Cells in memory, persisted in IndexedDB: bucket: { idb, node }
With bucket: { idb: "<database>", node }, a shard runs each cell’s SQLite in memory and
persists it through the bucket’s page-level logs (LTX) in IndexedDB, whose conditional writes
are atomic across tabs and workers. No OPFS SyncAccessHandle is opened, so nothing is exclusive
to one worker. Measured (examples/astro-in-browser, cells-lab.spec.ts, 48 cells × 10 writes,
then a reload): 8 shards 5.1 s (OPFS pools: 9.1 s), 16 shards 5.3 s (8.9 s); every cell read
back after the reload. Reads after a restart replay the log (1.4–2.2 s vs 0.6–0.9 s).
Cells on LTX files in OPFS: bucket: { ltx, node }
With bucket: { ltx: "<directory>", node }, a shard keeps each cell as celld keeps it: a snapshot
and one LTX file per commit, in an OPFS directory, in celld’s layout
(cells/<Class>:<idHex>/ltx/e1/0000/<min>-<max>.ltx). SQLite never holds the database: it reads
pages through a VFS, so a cell’s memory is its page cache, whatever its size.
shard worker LTX store worker (one per shard)
SQLite WASM ─ ltx VFS (src/adapters/ltx-vfs.ts) ltxCellStore (src/ltx/ltx-page-server.ts)
xRead ──► page cache (LRU, 2048 pages) ─ miss ──► readRun: one range read per file
xSync ──► the transaction's pages ───────────────► commit: one LTX file, appended
blocked on a SharedArrayBuffer (Atomics.wait) ◄── answer written into the buffer
opfsSyncFiles (src/ltx/opfs-sync-files.ts)
SyncAccessHandles in <directory>
- Reads. A page miss asks the store for a run of 16 pages (read-ahead); the store reads each
file the run touches once, by range. A file is opened by its header and page index only
(
src/ltx/ltx-pages.ts). - Commits. The journal is in memory (
journal_mode=MEMORY); atxSyncthe transaction’s pages become one LTX file, written under a temporary name and moved into place, so no reader sees a partial file. - Compaction. Past
compactAfterfiles (default 64), or once the files after the snapshot hold more than half of it, the store writes a new snapshot beside the old files (streamed: a run of pages and the index in memory), then swaps them between two requests. It checks the origin’s free space first and skips the compaction without room for the new snapshot. - One writer per cell. The store holds a Web Lock per cell, named after the directory and the cell, while the cell is open. A store that wants a cell another tab or worker holds waits up to 20 s (a reload’s previous page lets go a moment later), then fails the request.
- Needs cross-origin isolation (SharedArrayBuffer): COOP/COEP headers, which a ServiceWorker can add (examples/astro-in-browser does).
files: "opfs"uses OPFS’s asynchronous API instead of synchronous handles;"memory"keeps nothing (tests).
Measured (examples/astro-in-browser): EmDash’s site and its package store run on it; a 64 MiB
cell compacts with about 4 MB in memory (test/p30-ltx-pages.test.ts).
A project at the origin’s root: root
An app that builds absolute paths (EmDash’s admin calls /_emdash/…) cannot live under
<basePath><n>/. installProjectRouter(self, { basePath, root: { name, passthrough, hostPage } })
gives it the whole origin: every same-origin request outside basePath and the passthrough
prefixes (the host page’s own files) goes to project name with its URL unchanged, navigations
included. A navigation that finds no tab running the project’s shards is sent to hostPage with
?next=<the URL it asked for>, since nothing else of the origin could start them.
examples/emdash is served this way, from static files.
A sharded origin: CellRouter in the ServiceWorker
tab ── navigator.locks.request("cellb:shard:i") ──► holds shard i ──► spawns shard worker i
(pool cellb-shard-i)
ServiceWorker: CellRouter
ingress(req) ─► any live shard's Worker fetch (control headers stripped)
internal(req) ─► the shard of the target cell: shardOf(id, N)
WebSockets ─► control port per shard, socket ports handed to the page
- Each shard is one Web Lock, one OPFS pool and one Dedicated Worker (ADR 0005). A second tab queues on the locks, is served by the first tab’s workers, and takes the shards over when that tab closes.
createShardWorkerruns in each shard worker: its own cells stay local, others go upstream to the router. When the ServiceWorker restarts, it asks the pages to re-attach their links; the first request after a restart costs about one round trip to a page.- Each shard worker also holds an owner lock (
cellb:owner:<shard>:<uuid>). The router requests it (watchOwner); the browser grants it the moment the worker or its tab dies, so the router fails the shard’s in-flight requests within tens of milliseconds. A MessagePort reports nothing when its far side dies, so without this, requests would hang. - WebSocket sockets have a heartbeat for the same reason: the client pings every 10 s by default
and closes
1006after two missed pongs; the server end closes after three missed pings.
The repository wires this topology in its sharded demo (the page, the ServiceWorker and the shard
worker); the package provides CellRouter, createShardWorker, serviceWorkerConnector and
createWebSocketClass.
A fleet bucket on S3
With bucket, a browser node keeps its cells in the same bucket as celld nodes, in celld’s layout,
so cells move between this browser, other browsers and celld
(ownership-and-durability.md). A browser node:
- writes a node record celld reads: pressured, placement weight 0, and an unroutable address, so celld never pushes cells to it and refuses, rather than takes over, requests for cells it owns while it is alive;
- owns the cells requested through it, logs their commits as LTX (changed pages from the SQLite
WASM page hook), publishes their alarms in the wake index, and keeps R2 objects at
r2/<bucket>/<key>; - stops acknowledging writes when its node lease could have lapsed (a frozen tab), and releases its cells when the runtime closes.
The page signs each S3 request with SigV4 itself. There are two ways to let it reach the bucket.
Directly, with CORS on the bucket
The bucket must accept the page’s origin. The headers are those the S3 store sends and reads
(conditional writes for ownership, user metadata for R2’s celld-r2 record):
[
{
"AllowedOrigins": ["https://your-app.example"],
"AllowedMethods": ["GET", "PUT", "DELETE", "HEAD"],
"AllowedHeaders": [
"authorization", "content-type", "x-amz-date", "x-amz-content-sha256",
"x-amz-meta-*", "if-match", "if-none-match",
"cache-control", "content-disposition", "content-encoding", "content-language"
],
"ExposeHeaders": ["etag", "last-modified", "x-amz-meta-celld-r2", "content-type",
"cache-control", "content-disposition", "content-encoding", "content-language"],
"MaxAgeSeconds": 3600
}
]
AWS: aws s3api put-bucket-cors --bucket cells --cors-configuration '{"CORSRules": [...]}'.
RustFS and MinIO accept the same rules through their S3 API.
bucket: { s3: { endpoint: "https://s3.eu-west-1.amazonaws.com", bucket: "cells", prefix: "prod",
region: "eu-west-1", accessKeyId, secretAccessKey }, node: "browser-<id>" }
prefix is celld’s s3://BUCKET/PREFIX. Credentials in a page are visible to its user: give the
page a key limited to the bucket and prefix.
Through a same-origin proxy
No CORS is needed: the page sends to its own origin (/s3/...), and the proxy forwards to S3
unchanged. SigV4 covers the host and path, so the page signs as the real endpoint:
bucket: { s3: { endpoint: `${location.origin}/s3`, signAs: "https://s3.eu-west-1.amazonaws.com",
bucket: "cells", prefix: "prod", accessKeyId, secretAccessKey }, node: "browser-<id>" }
The proxy must keep the path after /s3, set Host to the real endpoint, and pass the body and the
authorization, x-amz-* and if-* headers through. Vite (as in the demo pages’ config):
server: { proxy: { "/s3": { target: "https://s3.eu-west-1.amazonaws.com", changeOrigin: true,
rewrite: (p) => p.replace(/^\/s3/, "") } } }
nginx:
location /s3/ {
proxy_pass https://s3.eu-west-1.amazonaws.com/;
proxy_set_header Host s3.eu-west-1.amazonaws.com;
proxy_request_buffering off;
}
In this origin’s OPFS
bucket: { opfs: "<dir>", node } keeps a fleet bucket in OPFS: persistence and hand-over between
tabs or workers with no server. Its conditional writes are atomic only within one writer.
What breaks
NoModificationAllowedErrorwhen a worker opens SQLite. Another worker holds the same pool name. Give each worker its ownpool, or let only the shard-lock holder open a shard’s pool.SAH pool is fullwhen many cells start at once. A burst of new databases or journals outran the pool’s growth (browserProjectRuntimestarts with 64 files). WithsahpoolOpener, pass a largerinitialCapacityfor workloads that open many cells together.AsyncLocalStorage.getStore()returns another cell’s store, orundefinedafter anawait, in a browser. The code’sawaitwas not lowered: a bundle of your own, not made bybundleWorkerForBrowserwith nodejs_compat, or a site made bycreateProjectSitewithoutnodeCompatfor a nodejs_compat project. Code in an async generator, or (on a site) in an async method that usessuper, sees no store after itsawaits.- Every S3 request fails with a CORS error in the console, or
PUTsucceeds but ownership never works. The bucket lacks a CORS rule, or does not exposeetag: conditional writes need it. SignatureDoesNotMatchthrough a proxy.signAsis missing or differs from the host the proxy sends to, or the proxy rewrote the path.storage quota exceeded writing …/ltx/…withbucket: { ltx }. The origin’s storage is full: the commit fails (the Durable Object resets, as on workerd, when its transaction had written) and nothing is left half-written. Past the quota, Chromium’sSyncAccessHandle.write()does not throw: it returns an error code as an unsigned count (4294967288), which the store checks for. In a Playwright contextnavigator.storage.estimate()overstates the room.ltx: cell … is open in another tab or worker (waited 20 s). Another store holds the cell’s Web Lock: another tab runs the same shard over the same directory, or a closed page’s store worker has not ended. Requests to that cell fail until it is released.ltx: short read of …in the console. A file changed under a cell’s view of it (a compaction swapped it); the store reopens the cell and reads again. Repeated, it means the directory is written by something else.- Two tabs on one OPFS bucket corrupt each other’s cells. Conditions are checked and then written; with two writers both can win. Use one tab or worker at a time, or S3.
- A WebSocket closes with
1006although nobody closed it. The heartbeat found the far end gone (20–40 s with the default 10 s period): the tab or worker at the other end was closed, frozen or killed. On a page’s main thread, background throttling can delay pings; pass worker-timers as the client’stimers. - Cookies through
installProjectRouterare readable by the page and allPath=/. A browser dropsCookiefrom everyRequestandSet-Cookiefrom everyResponseit builds, and a ServiceWorker’s responses set no cookies. Worker code’sRequestandResponsekeep both headers (a standaloneHeaders), the router sends the page’s cookies to the shard asx-cellb-cookieand stores the Worker’sSet-Cookievalues, which come back asx-cellb-set-cookie, with the Cookie Store API (browser-cookies.ts). Written by script, they cannot beHttpOnly; and since Chromium lets a ServiceWorker read cookies only for its default URL, every one is stored withPath=/. AResponsebuilt outside Worker code (fetch()’s, or the platform’s) still dropsSet-Cookie. - A
Requestarrives withoutUpgradeorSec-WebSocket-Protocol. Browsers drop forbidden headers from everynew Request(). CellB carries them as own properties andx-cellb-*headers, and theRequestglobal of Worker code carries them into its copies (new Request(req), as partyserver makes); a copy made with the host’s ownRequest(code outside the Worker bundle) loses them. - The first request after the ServiceWorker was stopped is slower (tens of milliseconds): the ServiceWorker asks a page to re-attach the shard links first. If no page is open, it fails.
- A browser fleet node owns cells but celld cannot reach them. By design: celld refuses those requests while the browser node lives, and takes the cells after its node lease lapses.
Firefox and WebKit
The suite is written against Chromium; pnpm e2e:firefox (Playwright’s own build:
npx playwright install firefox) runs it in Firefox, where it passes. It skips what Chromium skips
(the knownGap scenarios) and the two specs below.
Firefox 157 runs module ServiceWorkers, module Dedicated Workers, OPFS synchronous access handles (sahpool) and Web Locks. What differs:
- A forwarded request body arrives as the text
[object ReadableStream]. Firefox has noRequest.bodyand does not take aReadableStreamas a request body; it stores the stream’s string form.createCellRuntimegives Firefox arequest.body(a stream read fromarrayBuffer()on its first read, so the whole body is in memory first), and Worker code that reads it works (specs:celld-projectsandsite-projectskvandr2,miniflare). Code that passes it on,new Request(url, { body: request.body }), sends that text: passawait request.arrayBuffer(), or the request itself. The getter sits on a prototype belowRequest.prototype, not on it:@statewalker/webrun-http-streamsasks whetherRequest.prototypeownsbodyto decide whether it may build requests with stream bodies, and must keep hearing no. Where CellB rebuilds a request itself (a scheme restored on a node’s link, a URL given back on an isolate’s port) it reads the body whole there (spec:peer-routing). - Storage refusals are worded as V8 words them.
storage.putof a value structured clone refuses throws CellB’s ownTypeError, with the message of CellB’s V8 serializer (() => 1 could not be cloned.), not SpiderMonkey’s (Function object could not be cloned.), so it reads as on workerd and celld (spec:storage-details). - Under Playwright’s Firefox, a navigation the ServiceWorker lets through to the network leaves
the page uncontrolled (
navigator.serviceWorker.controller === null, after a reload or in a new tab), so its requests reach the dev server, not the router (JSON.parse: unexpected character). Playwright’s Firefox takes back every request the ServiceWorker does not answer. A ServiceWorker with no fetch handler, or one that answers the navigation, keeps control (e2e-browser/probes/sw-minimal.probe.spec.ts), so the sharded demo’s ServiceWorker answers navigations withfetch(request). Whether Firefox outside Playwright does the same is not checked. - The two
shardedspecs that stop the ServiceWorker use the Chrome DevTools Protocol and are skipped outside Chromium.
WebKit is not run, and is not a target for now. pnpm e2e:webkit is wired, but Playwright’s WebKit
build needs the system libavif16 (libavif.so.16), and fails to launch with Host system is missing dependencies to run browsers without it.