# Build a Splitwise-Style Expense Splitter With No Backend — 500 Lines, No Accounts, Just GenosDB

## What the "Clone" Guides Don't Tell You

Search for *how to build an app like Splitwise* and you get two kinds of answers. Agencies quote **12 to 24 weeks and a five-figure budget**. GitHub clones — MERN, Appwrite, Firebase — hand you a server, a database, an auth flow and a deployment before a single expense is split.

Both are answering a question you may not have asked. An expense splitter is a small problem: a few people, a list of who paid what, and one number per person. The size comes from the infrastructure, not from the problem.

This tutorial builds the whole thing in **one HTML file, about 500 lines, with no server and no accounts**. Two phones open the same link and see the same balances, live. Here is the finished app with a four-person trip loaded:

![Split expenses — a four-person trip: people, balances, who pays whom, and the expense list](https://cdn.hashnode.com/uploads/gql/6924809544867700748ef043/06886207-ebff-4b31-abff-d590790a6630.png)

- **Live demo:** [split-expenses.html](https://estebanrfp.github.io/gdb/examples/split-expenses.html) — open it in two windows.
- **Source:** [one file on GitHub](https://github.com/estebanrfp/gdb/blob/main/examples/split-expenses.html).

It runs on [GenosDB](https://github.com/estebanrfp/gdb), a graph database that lives in the browser and syncs peer-to-peer over WebRTC. Everything below is plain JavaScript against its API.

## The Whole Idea in Three Sentences

1. **The group is the link.** The name you give the group becomes the URL, and the URL is the room every device syncs in. Whoever holds the link is in — there is no invitation table because there is no table.
2. **An expense is a node.** Who paid, how much, split among whom. That is *everything* the app stores.
3. **A balance is never stored.** It is derived, on every change, from the whole set of expenses — which is what makes the sync visible: two windows either show the same balances or you can see they have not converged yet.

Everything else is HTML.

## Step 1 — The Group Is the Link

```js
import { gdb } from "https://cdn.jsdelivr.net/npm/genosdb@latest/dist/index.min.js"

// The link carries the group's name; the name is the database name; the
// database name is the room every peer syncs in.
const group = decodeURIComponent(location.hash.slice(1))
const db = await gdb(`split-${group}`, { rtc: true })
```

That is the entire backend. `gdb(name, { rtc: true })` opens a local database under that name — persisted in the browser's own storage — and joins the room of the same name. Peers discover each other through public relays that carry nothing but the handshake; the data itself travels **directly between browsers over WebRTC**. Close every tab and the data is still on each device; open one again and it syncs with whoever is there.

When the page opens without a hash, it shows a single field: *Name your group*. Submitting it writes the name into the hash and reloads. Sharing the link is the invitation.

## Step 2 — People and Expenses Are Nodes

A person is a node. An expense is a node. Both are written with `db.put`, which returns the node's id:

```js
// A person
await db.put({ type: "member", name: "Alice", at: Date.now() })

// An expense: who paid, how much (integer cents), split among whom
await db.put({
  type: "expense",
  title: "Dinner",
  cents: 9600,
  payer: aliceId,
  split: [aliceId, bobId, carolId, danaId],
  at: Date.now()
})
```

Amounts are **integer cents**, never floats. That is not pedantry: every peer will compute the balances on its own, and `0.1 + 0.2` is how two devices end up disagreeing by a cent forever.

Nothing else is written. There is no `balances` collection, no `group` document holding totals, no counter to keep in step. If you have ever debugged a cached total that drifted from its source, you know why.

## Step 3 — Two Live Queries Feed One View

The page subscribes once to each kind of node, and lets the engine sort:

```js
await db.map({ query: { type: "member" }, field: "at", order: "asc" }, ({ id, value, action }) => {
  action === "removed" ? members.delete(id) : members.set(id, value)
  renderMembers()
  renderBalances()
})

await db.map({ query: { type: "expense" }, field: "at", order: "desc" }, ({ id, value, action }) => {
  if (action === "removed") { expenses.delete(id); rowOf(id)?.remove() }
  else {
    expenses.set(id, value)
    const row = buildExpenseRow(id, value)
    if (action === "initial") list.append(row)       // arrives already newest-first
    else if (action === "added") list.prepend(row)   // newest by definition
    else rowOf(id)?.replaceWith(row)                 // updated: rebuilt in place
  }
  renderBalances()
})
```

`db.map` with a callback is a live query. It fires once per existing node (`initial`), then once per change — `added`, `updated`, `removed` — whether the change came from this window, another tab, or another device. There is no polling, no "refresh" button, and no second code path for remote changes: a peer's expense arrives through exactly the same callback as your own.

Two things fall out of this for free. A device that was offline writes locally and syncs when it is back — the callback fires on the other devices as if the expense had just been added. And a device that opens the link for the first time receives the whole set from whoever is online, one `initial` event per node.

## Step 4 — A Balance Is a Sum Over Everything That Arrived

This is the heart of the app, and it is fourteen lines:

```js
const balances = () => {
  const balance = new Map([...members.keys()].map((id) => [id, 0]))
  for (const { cents, payer, split } of expenses.values()) {
    const parts = split.filter((id) => balance.has(id)).sort()
    if (!parts.length || !balance.has(payer)) continue
    const share = Math.floor(cents / parts.length), rest = cents - share * parts.length
    balance.set(payer, balance.get(payer) + cents)
    parts.forEach((id, i) => balance.set(id, balance.get(id) - share - (i < rest ? 1 : 0)))
  }
  return balance
}
```

What you paid, minus your share of everything you took part in, over **every** expense. It runs on every change, and it costs nothing at the scale of a group.

Two details make it converge everywhere:

- **The split rounds down and hands the leftover cents to the first participants in id order.** 10.00 among three is 3.34, 3.33, 3.33 — and *which* person gets the extra cent is decided by sorting ids, so every device decides the same.
- **The balances of a group therefore sum to exactly zero**, always. If your app ever shows a group that does not, it is not a rounding bug: it is two peers that have not converged yet.

That second point is the reason to build the app this way. A stored total can be wrong quietly. A derived total is wrong *visibly* — and only until the last expense arrives.

## Step 5 — Who Pays Whom

Settling up is the classic greedy pass: the largest debtor pays the largest creditor, repeat. It produces at most *n − 1* transfers, and ties break on id so every peer lists the same ones:

```js
const settleUp = (balance) => {
  const byOwed = (a, b) => b.cents - a.cents || (a.id < b.id ? -1 : 1)
  const debtors = [...balance].filter(([, c]) => c < 0).map(([id, c]) => ({ id, cents: -c })).sort(byOwed)
  const creditors = [...balance].filter(([, c]) => c > 0).map(([id, c]) => ({ id, cents: c })).sort(byOwed)
  const transfers = []
  for (let i = 0, j = 0; i < debtors.length && j < creditors.length;) {
    const cents = Math.min(debtors[i].cents, creditors[j].cents)
    transfers.push({ from: debtors[i].id, to: creditors[j].id, cents })
    if (!(debtors[i].cents -= cents)) i++
    if (!(creditors[j].cents -= cents)) j++
  }
  return transfers
}
```

And when someone actually pays? **A settlement is one more expense** — paid by the debtor, split among exactly one person, the creditor:

```js
db.put({ type: "expense", kind: "payment", title: "Settlement", cents, payer: from, split: [to], at: Date.now() })
```

No special case, no second node type, no "mark as settled" flag to keep in sync. The derived balances close by themselves, and the transfer disappears from the list on every device.

## Step 6 — Open It in Two Windows

Add three people in one window. Add a dinner paid by Alice. The other window shows `Alice +20.00 · Bob −10.00 · Carol −10.00` the moment the node lands. Add a taxi from the second window, split between two — both windows now agree on `+15.50 · −5.50 · −10.00` and on the same two transfers. Mark one as paid, and it is gone from both.

Nothing in the page compares states or reconciles totals. It cannot: there is no total to reconcile. Each window derives its numbers from the expenses it holds, and the expenses are what the database keeps identical. When the sets are equal, the numbers are equal, and you can see it.

## What It Does Not Do — and What It Would Take

This is a tutorial, and it is honest about its scope:

- **Anyone with the link can add, edit and delete anything.** That is the Splitwise-with-friends model, and it is fine for a trip. It is not fine for strangers.
- **Two people editing the same expense at the same moment**: last write wins, deterministically, on every peer. Different expenses never collide.
- **The group's name is the link**, so a guessable name is a guessable group.

All three are the same decision — *no identity* — and GenosDB has the layer for the day you want it: a **Security Manager** that gives each device a cryptographic identity (a passkey, or a recovery phrase), signs every operation, and lets a node carry an `owner` so that only its author — and whoever they grant — can change it, enforced by every peer rather than by a server. Adding it to this app is a configuration option and one field per node; the [Security Manager guide](https://github.com/estebanrfp/gdb/blob/main/docs/sm-api-reference.md) and the [zero-trust model](https://github.com/estebanrfp/gdb/blob/main/docs/zero-trust-security-model.md) cover it.

## Run It

Open the [live demo](https://estebanrfp.github.io/gdb/examples/split-expenses.html) in two windows, or save [the file](https://github.com/estebanrfp/gdb/blob/main/examples/split-expenses.html) and open it from any static host. The only dependency is one import:

```js
import { gdb } from "https://cdn.jsdelivr.net/npm/genosdb@latest/dist/index.min.js"
```

The [examples catalogue](https://github.com/estebanrfp/gdb/blob/main/docs/genosdb-examples.md) has forty more pages built the same way, and the [API reference](https://github.com/estebanrfp/gdb/blob/main/docs/genosdb-api-reference.md) covers every call used here.

⭐ Found this useful? **[Star GenosDB on GitHub](https://github.com/estebanrfp/gdb)** — or spin it up in seconds: `npm i genosdb`.

---

> **This article is part of the official documentation of GenosDB (GDB).**  
GenosDB is a distributed, modular, peer-to-peer graph database built with a Zero-Trust Security Model, created by **Esteban Fuster Pozzi ([estebanrfp](https://github.com/estebanrfp))**. 

📄 [Whitepaper](https://github.com/estebanrfp/gdb/blob/main/WHITEPAPER.md) | overview of GenosDB design and architecture 

🛠 [Roadmap](https://github.com/estebanrfp/gdb/blob/main/ROADMAP.md) | planned features and future updates

💡 [Examples](https://github.com/estebanrfp/gdb/blob/main/docs/genosdb-examples.md) | code snippets and usage demos

📖 [Documentation](https://github.com/estebanrfp/gdb/blob/main/docs/index.md) | full reference guide

🔍 [API Reference](https://github.com/estebanrfp/gdb/blob/main/docs/genosdb-api-reference.md) | detailed API methods

📰 [Articles](https://genosdb.com) | concepts, tutorials and use cases

💬 [GitHub Discussions](https://github.com/estebanrfp/gdb/discussions) | community questions and feedback

🗂 [Repository](https://github.com/estebanrfp/gdb) | Minified production-ready files

📦 [Install via npm](https://www.npmjs.com/package/genosdb) | quick setup instructions

🌐 [Website](https://estebanrfp.com/) | [GitHub](https://github.com/estebanrfp) | [LinkedIn](https://www.linkedin.com/in/estebanrfp/)

