MSRXMSRX SplitMSRX home

How MSRX Split works

No server holds your group. Your devices keep it, talk to each other directly, and lock everything with a key made from your group code.

Last updated 7 October 2026

In plain words

Where is my data?

On your phone or computer, inside your browser. Nothing is stored on an MSRX server, because there is none holding your groups. Every member's device keeps its own full copy of the group.

That is why the app is free to run and why it can keep working with no signal: you add an expense, it is saved on your device, and it reaches the others when you are next online together.

How do everyone's phones stay in step?

When two members have the group open at the same time, their devices connect to each other directly and swap whatever the other is missing. A small introduction service helps them find each other, like a receptionist who says who is in the room but never sees what you say.

If nobody else is online, your changes simply wait on your device. Tap Sync now, or open the group when a friend does, and they go across.

What is encrypted?

When two devices in your group talk, the connection between them is encrypted, and the details they swap to set it up are locked with a key made from your group code. The introduction service only ever sees a scrambled room name and those locked details. It cannot read your expenses, names or amounts. Backup files are locked with your group code too.

The lock symbol at the top of the app is a reminder of this: communication within your group is encrypted.

What is the group code?

A random 10-character code made on your device when you create the group. It is both the key and the address of the group. Anyone who has it can open the group, so share it only with people on the trip.

Each person you add also gets their own short personal link, so the right name is set up on the right phone. The personal part is a label, not a password.

Who is the Admin?

Whoever creates the group. Admins can add and remove people, make others admin, rename the group and close the trip. If the last admin leaves, the next person who joined takes over, and so on down the line.

These roles are rules the app follows. They keep honest groups tidy; they are not a vault. If you need to be sure someone is shut out, the admin moves the group to a new code and shares it only with the people who stay.

What if I lose my phone?

The group is still on everyone else's devices. Download a backup file from the Data tab now and then: with the file and the group code you can bring the whole trip back on a new device. Without a backup and with every device lost, there is nothing to recover from, and that is the price of keeping no copy anywhere.

What is Ask Split?

A helper you can ask about the trip's spending: the total, what you owe, who paid most, where the money went, what the trip will cost by the end and whether you are on budget. Those quick questions are worked out on your phone, from your group's own data, and work with no signal.

Typed questions use AI and are off unless an admin turns them on and you agree on your own phone. Before anything is sent, names are swapped for labels like P1 and P2, and expense titles, the group name and the code stay behind. The answer comes back with the names put back in on your phone.

What does it not do?

What does it cost?

Nothing. It runs on free plans of the hosting and introduction services, which is why those services have daily limits and the app is built to keep working on your devices even when they are unavailable.

For engineers

Architecture

MSRX Split is a local-first single-page app. There is no application server holding data and no database. The static bundle is served from Vercel; the server-side code is a tiny signalling relay on Cloudflare Workers that introduces peers, and one stateless function for the optional Ask Split AI. Group state lives in each browser's IndexedDB and converges by exchanging CRDT updates over WebRTC data channels.

Data model

Each group is a Y.Doc with three Y.Maps keyed by id (meta, people, expenses) and a Y.Array activity log. Records are objects replaced whole, so concurrent edits to the same record resolve last-writer-wins by Yjs's deterministic ordering, while concurrent additions never conflict.

Nothing is ever hard-deleted. Removing an expense, a person or a member sets a flag (tombstone), which is what lets history merge cleanly after long offline periods and lets a device that was away for months catch up without losing or resurrecting anything.

Money is held as integer minor units (paise) and split with a largest-remainder allocation so shares always sum exactly to the total. The ledger (who owes whom, and a short list of payments that settles everyone) is derived from the expenses on every render and is never stored.

Keys and encryption

The group code is 10 characters drawn uniformly from a 56-character alphabet without look-alike characters (no 0, O, 1, I, l, L), about 58 bits of entropy. It is run through PBKDF2-HMAC-SHA-256 with 600,000 iterations and a fixed domain-separated salt, and the result is split by HKDF-SHA-256 into three independent secrets:

Group updates travel over WebRTC data channels, which are encrypted with DTLS between the two devices; a TURN relay carries only those encrypted bytes. The DTLS certificate fingerprints are inside the offers and answers, which are encrypted with signalSecret, so the signalling relay can neither read them nor swap in its own to sit in the middle. PBKDF2 was chosen over Argon2 because it ships in every browser's WebCrypto with no extra download; the iteration count is what makes guessing a code from a leaked room name expensive. Derived keys are never stored: they are recomputed from the code each time a group opens.

Sync and the relay

On opening a group the client derives keys, fetches ICE servers, and joins the room through y-webrtc. Peers exchange Yjs state vectors and send only the missing updates. The connection status shown in the app is computed from real RTCPeerConnection state, not from peer discovery, so it never claims a link that does not exist.

The relay is a Cloudflare Worker routing WebSocket upgrades to a single Durable Object. It implements the y-webrtc signalling protocol (subscribe, unsubscribe, publish, ping) over WebSocket hibernation, keeps topic membership in memory only, and holds no persistent state. It enforces per-socket limits: 64 KiB per message, 8 topics per connection, 60 connections per topic and 120 messages per 10 seconds, and caps open connections at 3,000 in total and 64 per address. An origin allow-list restricts it to the app's own site.

Because the relay is only a rendezvous, the free-plan daily limits affect discovery of new connections, not data. Existing direct links, and everything on each device, keep working if the relay is unavailable.

STUN and TURN

Devices find their public address with a Google STUN server (stun.l.google.com:19302). When a network blocks direct links, traffic falls back to Cloudflare's TURN service. TURN credentials are minted by the relay Worker at GET /ice: the long-lived API token never leaves the Worker, each credential expires after 12 hours, requests are origin-checked and rate-limited (30 per address and 3,000 in total per hour), and only hosts under cloudflare.com are accepted. If anything fails, the client fails open to STUN only. A relayed connection carries only DTLS-encrypted bytes it cannot read.

Invite links

A link is https://split.msrx.co.in/#/join/<code>~<tag>. Everything after the # stays in the browser and is never sent to any server. The 4-character tag is derived from a member id with FNV-1a; it pairs a device with a person and is not a secret.

An optional longer form appends a snapshot of the group (raw deflate, base64url, at most 3,000 characters, inflate capped at 256 KB) so a new device can show the group, and who runs it, before any peer is online. A snapshot is applied only to a new or still-empty group, so a stale link can never overwrite live data. Opening a link first shows a consent screen; nothing is saved until the person agrees.

Roles, and what they do not guarantee

The creator is flagged admin. Add, remove, restore, promote, demote, rename and close are admin-only in the app, every one is written to a readable activity log, and when the last admin leaves the member with the earliest join time inherits the role. Groups created before roles existed treat every active member as an admin until one is chosen.

These are application rules, not cryptographic ones: the document is one shared CRDT that anyone holding the group code can write, so a modified client could ignore them. The log makes tampering visible rather than impossible. The real revocation tool is moving the group: a new code is generated, history is copied, the old group is closed, and the new code is shared only with people who stay. Making roles tamper-proof would need per-member signing keys and a signed history, which is a larger design than this app takes on.

Ask Split and the AI path

Insights are computed on the device from the same per-expense split as the balances: daily average over the calendar days the spending covers, a straight-line forecast to the trip's last day, and the budget measured against both. The eight quick questions are answered by code on the device; there is no model involved and no request made.

Typed questions, when an admin has set the group's ai flag and this device has recorded its own consent, take two steps through a stateless Vercel function, /api/ask. First the question alone, with member names masked to P1, P2 and so on, is classified by TypeSafe's Jev model (one Choice question): a question that is one of the quick ones at probability 0.8 or above is answered on the device, an unrelated one gets a fixed reply, and only the rest go on. Then the device sends a summary built in code (labels, amounts in major units, categories, dates, budget, forecast and settle-up transfers, plus the budget verdict and the daily allowance to stay within it, both computed in code so the model never has to judge them; no titles, group name or code) and Groq's openai/gpt-oss-20b writes the answer at low reasoning effort, which the device unmasks. If the classifier is unavailable, questions go straight to the model.

The function holds the GROQ_API_KEY and TYPESAFE_API_KEY server-side, accepts only same-origin requests from the app's own hosts, caps the question at 300 characters and the summary at 16 KB, rate-limits per address and per instance, and never logs a request body. Answers are rendered as plain text, so nothing a model returns can run as code in the page.

Storage and backups

Updates are written to IndexedDB by y-indexeddb and the app asks the browser for persistent storage. Backups are the encrypted Yjs state in a single file, openable only with the group code, and a plain CSV export is available for spreadsheets. The app nudges for a backup after a few entries or a day. Removing a group from a device deletes its IndexedDB database and registry entry from that device only.

Browser security

Testing

Unit tests (Vitest) cover the ledger and money maths, key derivation, the CRDT model, roles and succession, invite encoding and the relay itself. A claims test checks the privacy notice and this page against the code. Playwright end-to-end suites run real browsers (Chromium and an iPhone-profile WebKit) through creating, joining, syncing, backup and restore, admin succession, legacy groups and moving a group, plus a check of the live site under the production CSP and a run that forces traffic through Cloudflare's real TURN service.

Known limits

Built with

Versions are the ranges this build declares.

In the app

PackageVersionWhat it does
React^19.3.0The screens you see
React DOM^19.3.0Draws React in the browser
ws^8.22.0WebSocket server for running the relay locally and in tests
y-indexeddb^9.0.12Saves each group in the browser's own storage
y-webrtc^10.3.0Connects members' devices directly and exchanges Yjs updates
Yjs^13.6.33The shared-document engine (CRDT) that merges everyone's changes

To build, test and deploy

PackageVersionWhat it does
Vite React plugin^6.1.2Compiles React for Vite
Playwright^1.63.0Real-browser end-to-end tests
TypeScript^7.0.2Typed JavaScript
Vite^8.3.3Build tool and dev server
vite-plugin-pwa^2.0.0Makes the app installable and available offline
Vitest^5.0.3Unit tests
Wrangler^4.147.0Deploys the relay to Cloudflare

Services

Questions about any of this: mrinalsinghraja@gmail.com. The Privacy notice and Terms are the legal side.