SDK 0.31: a browser runs a full Offline Protocol node
TL;DR
SDK 0.31.0 runs the full engine, MLS included, in a browser worker with durable storage. Browsers link over WebRTC, find each other on one network, reach any device over the app's Nostr relays, and link to phones or boards over Web Bluetooth.
A web page has always been a client: it reaches a server, and the server decides who else hears from it. Version 0.31.0, released on 27 October 2026, makes the browser a node like a phone or a box. It mints its own identity, forms MLS sessions, creates and administers groups, and relays for other devices, with the same Rust engine every other binding runs.
The engine crate, offline-protocol-web, is on crates.io, and the JavaScript that runs it is in bindings/web in the SDK repository. A browser node keeps its identity across reloads, links to other browsers over WebRTC, finds the browsers on its own network, reaches any device over the application’s Nostr relays, and in Chromium links to a phone or a board over Bluetooth LE. The binding is experimental, and every test page runs in Chromium, Firefox and WebKit.
Offline Protocol in the browser: a full node in a Web Worker
The engine compiles for wasm32-unknown-unknown with MLS included and runs in a dedicated Web Worker. The page holds an OfflineNode, which posts calls to the worker; the worker ticks the engine every 100 ms and drains its outbound frames and events. Every engine event reaches the page as a DOM event named by its type. This is the node setup of the two-tabs example:
// Excerpt from examples/web-two-tabs/index.html
import { OfflineNode } from "../../bindings/web/src/index.js";
// In memory: every tab is its own device, so any number can be open at once.
// A durable node is one per account per origin (see examples/web-lobby).
const node = await OfflineNode.start({ appId: "web-two-tabs-demo", storage: "memory" });
$("me").textContent = node.address;
node.addEventListener("secure_session_established", ({ detail }) => {
neighbours.set(detail.peer_id, { secure: true });
render();
});
node.addEventListener("message_received", ({ detail }) => {
note(`${short(detail.sender)}: ${detail.content}${detail.encrypted ? " 🔒" : ""}`);
});
Open it in two tabs and each shows its own off1… address, then the other tab as a neighbour with an encrypted session, within a second or two. Tabs of one origin link over a BroadcastChannel; everything about the link that matters is decided in Rust, by the same peer-stream framing every binding uses. The engine checks the identity preamble that proves who is on the other end, holds one link per address, and refuses a link whose first frame does not verify.
The worker is a requirement of the storage, not a style choice. The engine’s storage is synchronous, and a returned Ok means the write is durable, because OpenMLS saves group state inside every encrypt. The only browser storage that is synchronous and can flush is an OPFS sync access handle, and browsers give those only to dedicated workers. Running in a worker also keeps MLS work off the thread a game renders on.
Durable browser storage on OPFS
A browser node is durable by default. Each storage category, MLS material and protocol state, is an append log in origin-private files, and every write appends one record and flushes the file before it returns. Every record is sealed with ChaCha20-Poly1305 and bound to its category, the file’s generation and its sequence number, so a record moved anywhere else does not authenticate. The store key is itself sealed under a non-extractable WebCrypto key kept in IndexedDB.
- The same device after a crash or a reload. A node that restarts comes back with the same address, every write the engine acknowledged, and its group memberships. The browser’s restart test terminates a node mid-session and checks that it reads the group’s next message with no decryption failure.
- One node per account per origin. The worker holds a Web Lock named for the account for its whole life, so a second node for that account is refused before it opens a file. A page that wants several nodes gives each its own
profile, or passesstorage: "memory"for a new device on every start. - Damage is refused, not cut away. A log under the wrong key is refused at open with nothing truncated, and a damaged record followed by intact ones is refused rather than cut, because cutting there would roll back acknowledged ratchet state. A torn tail from a crash during a write is what gets dropped.
The rules, and what the sealing protects at rest, are in W8 of the web bridge contract.
WebRTC between browsers without a server
WebRtcCarrier links two devices with one reliable, ordered data channel, set up from two codes. Each side’s description waits for ICE gathering to finish, so one code carries every candidate, and the people move the codes by QR code or paste. No ICE server is configured by default, so the link forms on the local network:
const carrier = new WebRtcCarrier(node);
const offered = await carrier.offer(); // show offered.code as a QR code
await offered.accept(answerCode); // the code the other device answered with
// On the other device:
const answered = await new WebRtcCarrier(node).answer(offerCode);
// show answered.code to the device that offered
new WebRtcCarrier(node, { iceServers }) takes the application’s own STUN and TURN servers for links between networks. The carrier reports each connection’s state as a connection event, so a page can warn about a link seconds before the browser gives up on it. A connection that stays disconnected for 15 seconds is closed and reported lost, so the same device can link again after it restarts; Android’s WebView was seen to stay in disconnected for good otherwise.
For a short code instead of two long ones, NostrSignaller carries the two connection codes through Nostr relays the application names. Both sides derive the room’s topic and sealing key from the room code, a generated code carries eighty bits, each event is signed by a key used once, and a hosted room redials a relay that drops and stays reachable across sleep and network changes.
Finding nearby browsers on the same network
A page cannot scan for Bluetooth, browse DNS-SD or join Wi-Fi Direct, so NearbyRoom gives the browsers behind one network address a place to meet. The room is derived from the application id and the public address a STUN server reports. Each device announces itself every 5 seconds, lists the others it heard within 15 seconds by the label each application chose, and links over WebRTC to the one its person picks. The relays carry only the setup, and the link runs on the local network.
const room = new NearbyRoom(node, carrier, {
relays: ["wss://relay.example.com"],
iceServers: [{ urls: "stun:stun.example.com" }],
label: "Ana's laptop",
admit: ({ label }) => confirm(`Let ${label} connect?`),
});
await room.open();
room.addEventListener("peers", () => render(room.peers));
await room.connect(room.peers[0].peer);
The room key is public by construction, so the SDK seals every connection code to one device’s key, signs every presence with the key it announces, and asks the application about every offer through admit, once per session. The relays and the STUN server are the application’s; the SDK names none (W13).
Nostr transport in the browser
OfflineNode.start({ appId, nostr: { relays } }) adds the Nostr transport every binding shares, driven from the worker over the relays the application names. A message to a bare off1… address fetches the key package that device published and forms the session, so two nodes that share only a relay can talk. It is the same transport and wire format the mobile bindings run.
establishSecureSession(address)answers"created","existing"or"pending"; for a device met only by its address, pending is the ordinary first answer, andsecure_session_establishedfollows.usernameDiscovery: truepublishes the profile as a username and letsresolveUsernameread the directory. It is off by default, because it binds a readable name to the address in public.createInviteandparseInvitematch the native calls, and a tampered invite is refused.
The SDK refuses a nostr option with an empty relay list, and no file it ships names a relay, so the relays an application uses are always its own choice.
Web Bluetooth from a browser to a phone or board
In Chromium, BluetoothCarrier makes the page a GATT central to a phone or a board running the SDK as a peripheral. The person picks the device in the browser’s chooser, inside a click, or the page reconnects to a device this origin was granted before. The engine reads and verifies the identity the device serves, the page writes its hello, and the link carries sessions and messages through the same BleTransport and fragment codec as every binding.
const node = await OfflineNode.start({ appId: "com.example.app", ble: true });
const bluetooth = new BluetoothCarrier(node);
pair.addEventListener("click", async () => {
const peer = await bluetooth.request(); // the chooser; resolves to the verified address
console.log("linked", peer);
});
A peer is announced only under the address its identity proves, and a device that serves no Hello or refuses the write is disconnected rather than announced as a neighbour that cannot hear. The Android and Python peripherals serve Hello, and in 0.31.0 the iOS peripheral does too, so an iPhone can name a browser that connects to it. Firefox and Safari have no Web Bluetooth, and neither does any browser on iOS, so this link needs Chromium on a desktop or Android.
Groups and ephemeral sends in the browser
A browser could already create and administer a group. In 0.31.0 it can also shrink one: removeFromGroup moves the other members to a new epoch whose keys the removed member never holds, and groupInfo returns a group’s members and epoch.
Ephemeral sends are new for state the next send replaces. sendMessage(to, content, { ephemeral: true }), and the same option on sendGroupMessage, send once over a live direct link, ask for no acknowledgement and keep nothing to retry, so a game’s positions never arrive late. With no link, the call fails at once. A group member who keeps missing them gets one reliable message every 256 sends, so their ratchet never falls out of reach. On the browser’s store an ephemeral group message costs the sender two flushes, against four for a reliable one. The engine has them and the browser exposes them; the native bindings do not carry the option yet.
The Pong lobby puts it together: two players, durable nodes, an MLS group the host creates, and the game state sent to it ten times a second as ephemeral sends. On a MacBook running Chrome and a Samsung Galaxy M36 on one Wi-Fi network, the link formed 30 to 153 ms after Connect and state flowed at 10 updates a second each way, in both directions. A player that reloaded came back as the same address and was let back into the game with no second invite (two-device runs).
Testing the browser binding in Chromium, Firefox and WebKit
CI’s web binding job runs every page in bindings/web/test in Chromium, Firefox and WebKit through Playwright: two nodes over tabs, a durable restart, WebRTC with a 100 KiB message in chunks, a Nostr room, the Nostr transport with a bare address and a username, the Bluetooth carrier against peripherals that are other nodes, and four nodes in nearby rooms. Both storage conformance suites run against the log store over real OPFS files in a worker of Chrome and Firefox, with a reopen through compaction.
Running in three engines paid for itself at once: the first run in Firefox found that waiting on navigator.storage.persist() hung every durable start there, because Firefox answers it by asking the user. 0.31.0 makes the request without waiting for the answer. A two-device checklist covers a phone’s browser, a local network and a tab the phone suspends.
Building the Offline Protocol web binding
Build the module and its JavaScript from the SDK repository, then serve the examples:
cargo install wasm-bindgen-cli --version 0.2.105 --locked # once; must match Cargo.lock
bash bindings/web/scripts/build.sh
python3 -m http.server 8000 --bind 127.0.0.1 # from the repository root
A page that runs a node needs a secure context, HTTPS or localhost, because OPFS, Web Locks and WebRTC exist only there. It listens for fatal and starts the node again if the worker ends, and for a Bluetooth link it calls BluetoothCarrier.request() from a click. A host whose worker starts from a Blob URL, such as an editor plugin, passes the module’s bytes as OfflineNode.start({ wasm }). The contract is the web bridge document, and the Learning Center article on the browser covers where it fits beside the other bindings.
The rest of 0.31.0, a Ruby binding, Flutter at parity with React Native, live Bluetooth LE signal strength and sessions that heal themselves, is in the companion post. Read the source on GitHub, or talk to us about putting a browser on the same network as your devices.
Frequently asked questions
Is the Offline Protocol browser package on npm?
Not yet. The engine crate, offline-protocol-web, is on crates.io at 0.31.0, and the JavaScript that runs it is in bindings/web in the SDK repository, built with bindings/web/scripts/build.sh. The binding is experimental.
Which browsers run the Offline Protocol web binding?
CI runs every browser test page in Chromium, Firefox and WebKit through Playwright, and the storage suites over real OPFS files in Chrome and Firefox. Web Bluetooth is Chromium only, so linking a page to a phone or a board over Bluetooth LE needs a Chromium browser.
Does a browser node keep its identity after a reload?
Yes. Storage is durable by default, as sealed append logs in origin-private files, so the same profile comes back as the same off1 address and still a member of its groups. One node runs per account per origin, and storage set to memory makes a new device on every start.
Do browsers need a server to talk to each other?
Not on one network. Two browsers link over WebRTC from two codes the people exchange by QR code or paste, with no ICE server configured. A room code, a nearby room or the Nostr transport use relays and a STUN server the application names; the SDK names none.
What is an ephemeral send in SDK 0.31?
A message for state the next send replaces, such as a game's positions. It goes once over a live direct link, asks for no acknowledgement and is never retried, so it never arrives late. The browser exposes it as an option on sendMessage and sendGroupMessage.