Shared-expense tracking with real-time updates delivered as QUIC DATAGRAM frames over WebTransport.
- Splitwise-QUIC
Splitwise-QUIC is a fully working Splitwise-style expense splitter, built as a deliberate playground for the complex QUIC techniques you rarely see wired together in one real application.
Your browser bootstraps over HTTP/2 (TCP/TLS), reads the Alt-Svc header,
and transparently upgrades to HTTP/3 over QUIC (UDP) for every subsequent
request — both served on the same port. On top of that, live balance updates are
pushed to the browser as unreliable QUIC DATAGRAM frames via WebTransport,
with a graceful Server-Sent-Events fallback for non-Chromium browsers.
It's a real app (groups, multi-currency expenses, debt simplification, settle-up) that happens to be an end-to-end QUIC showcase.
| HTTP/3 first | Every page and partial is served over QUIC; TCP exists only to bootstrap the upgrade. |
| Live over datagrams | Real-time UI updates via QUIC DATAGRAMs (WebTransport) + SSE fallback. |
| Correct money math | Integer minor units everywhere; zero lost pennies across any split. |
| 4 split modes | Equal, exact, percentage, and weighted shares. |
| Multi-currency | Each currency's balances tracked and simplified independently. |
| Debt simplification | Max-heap greedy cash-flow minimization — O(n log n), ≤ n+m-1 transfers guaranteed. |
| Self-contained binary | Templates + static assets embedded via go:embed; pure-Go SQLite (no cgo). |
| Zero-config TLS | Fresh short-lived ECDSA cert minted on every boot for WebTransport cert-hash pinning. |
An interactive, zoomable version with multiple diagrams (system, request lifecycle, data model, QUIC handshake) lives in
docs/architecture.html— just open it in a browser.
flowchart TB
subgraph Client["Browser"]
UI["HTMX + Tailwind UI"]
JS["app.js<br/>protocol badge / split UX"]
WTC["WebTransport client<br/>(QUIC datagrams)"]
end
subgraph Server["Go server :4433"]
direction TB
TCP["TCP listener<br/>HTTP/1.1 + HTTP/2"]
UDP["UDP listener<br/>HTTP/3 + QUIC"]
ALT["Alt-Svc middleware<br/>(advertise h3)"]
MUX["http.ServeMux<br/>+ auth middleware"]
H["Handlers<br/>pages / actions / partials"]
RT["realtime.Hub<br/>(pub/sub)"]
ST["store<br/>(persistence)"]
SP["splits<br/>(money math + debt)"]
end
DB[("SQLite<br/>WAL")]
UI -->|"HTTP/2 bootstrap"| TCP
UI -->|"HTTP/3 (after Alt-Svc)"| UDP
WTC -->|"QUIC DATAGRAMs"| UDP
TCP --> ALT
UDP --> ALT
ALT --> MUX --> H
H --> ST --> DB
H --> SP
H -->|publish| RT
RT -->|"SSE + datagrams"| UI
RT -.->|datagrams| WTC
This project intentionally stacks the "hard" parts of QUIC into one app:
| Technique | Where it lives |
|---|---|
| HTTP/3 over QUIC (TLS 1.3 mandatory) | internal/server/server.go |
| 0-RTT session resumption | quic.Config{ Allow0RTT: true } |
| QUIC DATAGRAMs (RFC 9221) | EnableDatagrams: true + WebTransport push |
| WebTransport live channel (browser) | internal/handlers/realtime.go, static/app.js |
| Per-user push over datagrams | topic-based realtime.Hub + /wt endpoint |
| Receipt upload over a QUIC stream | multipart body on HTTP/3 = a dedicated stream |
| Stream multiplexing tuned high | MaxIncomingStreams: 512 (no head-of-line blocking) |
| Connection migration friendliness | keep-alive + QUIC path validation |
| Alt-Svc TCP -> QUIC upgrade hint | withAltSvc middleware |
| Mutual TLS (optional) | REQUIRE_MTLS=1 |
| Short-lived ECDSA cert for cert-hash pinning | internal/server/tls.go |
- Auth — email/password with bcrypt hashing + opaque session cookies
- Groups — create groups, add members
- Expenses with four split modes:
- Equal — divided evenly; leftover cents go to the first participants
- Exact — explicit amounts that must reconcile to the total
- Percentage — basis-point precision, must total 100%
- Shares — weighted (e.g. 2:1 => two-thirds / one-third)
- Multi-currency — balances computed per currency, never mixed
- Debt simplification — minimizes the number of "who pays whom" transfers
- Settle-up — record direct payments that clear balances
- Expense editing — edit any expense in place (htmx-swapped form, re-computes shares)
- Comments — threaded notes on each expense
- Receipt uploads — attach a photo to an expense (streamed over QUIC)
- CSV / PDF export — download a group's expenses (CSV) or a full report (PDF)
- Activity feed — human-readable audit trail per group
- Real-time — instant UI refresh via QUIC datagrams (WebTransport) or SSE
- Per-user push notifications — personal alerts delivered as QUIC datagrams on any page (added to a group, a new expense, a settlement)
- Dark / light mode — starfield theme with a persistent toggle
Prerequisites: Go 1.26+
# from the project root
go run .Then open https://localhost:4433.
The dev server uses a self-signed certificate, so your browser will warn once. Accept it to proceed (a fresh cert is minted on every boot).
Build a binary instead:
go build -o splitwise-quic .
./splitwise-quic -addr :4433 -db splitwise.db -uploads uploadsdocker compose up --build
# or, on a restricted module-proxy network:
# docker build --build-arg GOPROXY=direct -t splitwise-quic .Then open https://localhost:4433. The SQLite DB and uploaded receipts
persist in the sqquic-data volume.
kubectl apply -f deploy/k8s.yamlRuns as a single replica (SQLite is single-writer) with a ReadWriteOnce PVC
for /data. Liveness/readiness probes hit /healthz. Note that QUIC needs the
UDP port exposed alongside TCP — a mixed-protocol LoadBalancer (k8s 1.26+) or
two Services.
Most system curl builds don't ship HTTP/3 support, so a tiny QUIC client is
bundled:
go run ./cmd/h3check https://localhost:4433/login
# -> OK over HTTP/3.0 -> 200 OK (3211 bytes)Check the Alt-Svc upgrade hint over plain TCP:
curl -sk -D - -o /dev/null https://localhost:4433/login | grep -i alt-svc
# -> alt-svc: h3=":4433"; ma=2592000In the browser, the green proto: h3 badge in the header confirms you're on
HTTP/3, and the pulsing live dot on a group page confirms the WebTransport
datagram channel is connected.
splitwise-quic/
├── main.go # entry point: flags, wiring, graceful shutdown
├── cmd/
│ └── h3check/ # standalone HTTP/3 client (smoke test)
├── internal/
│ ├── models/ # domain types (User, Group, Expense, ...)
│ ├── db/ # SQLite connection + schema migration
│ ├── store/ # persistence (users, groups, expenses, balances)
│ ├── splits/ # PURE money math: split modes + debt simplification
│ ├── server/ # QUIC/HTTP3 transport, TLS, Alt-Svc, listeners
│ ├── realtime/ # in-memory pub/sub hub
│ ├── render/ # embedded templates (HTMX) + static assets (JS)
│ └── handlers/ # HTTP handlers, routing, SSE, WebTransport,
│ # edit/comments/receipts/export
├── deploy/
│ └── k8s.yaml # Kubernetes Deployment + Service + PVC
├── docs/
│ ├── architecture.html # interactive Mermaid architecture diagrams
│ └── BYDEV.md # technology glossary + layer-by-layer guide
├── Dockerfile # multi-stage, distroless, non-root
├── docker-compose.yml
└── README.md
Every file is comfortably under 600 lines, and the splits package is pure
(no I/O) so the tricky money logic is trivially testable.
- Browser makes its first request over HTTP/2 (TCP/TLS).
- Server responds with an
Alt-Svc: h3=":4433"header. - Browser remembers this and uses HTTP/3 over QUIC (UDP) for subsequent requests — same port, different transport.
- WebTransport (fast lane):
app.jsopens a WebTransport session pinned to the server's SHA-256 cert hash and reads QUIC DATAGRAM frames. Each event triggers an HTMX partial refresh. - SSE (fallback): the page also subscribes via the HTMX SSE extension, so browsers without WebTransport still get live updates.
Both are fed by the same realtime.Hub — handlers Publish an event after any
mutation, and every subscriber (SSE stream or WT session) fans it out.
All amounts are stored as integer minor units (cents). Floating point only appears at the input-parsing boundary. Equal splits give leftover cents to the first participants; percentage and shares use largest-remainder rounding — so shares always sum to the exact total.
Net balances per currency feed a max-heap greedy cash-flow minimization algorithm
(internal/splits/simplify.go):
- Build two max-heaps — one for creditors (net > 0), one for debtors (net < 0).
- At each step, pop the largest creditor and largest debtor.
- Settle
min(debtor, creditor)between them, then re-insert whichever side has a remainder. - Repeat until both heaps are empty.
This guarantees at most n + m − 1 transfers (the theoretical minimum for n debtors and m creditors), while always re-evaluating the largest remaining obligation after every partial payment — something a sort-once two-pointer scan cannot do. Time: O(n log n). Space: O(n).
Error handling: zero-balance entries are skipped; an imbalanced ledger (sum ≠ 0) is logged but does not abort — the algorithm clears as much debt as possible.
splits.Compute returns typed sentinel errors (use errors.Is):
| Error | Trigger |
|---|---|
ErrNonPositiveTotal |
Total ≤ 0 |
ErrEmptyInputs |
No participants |
ErrDuplicateUser |
Same UserID appears twice |
ErrNegativeValue |
Negative amount/weight/percentage |
ErrOverflow |
total × share-weight exceeds int64 |
ErrBadSplit |
Exact amounts or percentages don't reconcile |
ErrInvalidSplitType |
Unknown split type string |
| Flag / Env | Default | Description |
|---|---|---|
-addr |
:4433 |
Listen address (used for both TCP and UDP) |
-db |
splitwise.db |
SQLite database file path |
-uploads |
uploads |
Directory for uploaded receipt images |
REQUIRE_MTLS |
unset | Set to 1 to require mutual TLS (clients must present a cert) |
go test ./... # split math + debt-simplification correctness
go vet ./... # static analysisThe test suite covers: guard conditions (zero/negative total, empty inputs, duplicate user, negative values), equal-split penny distribution, exact-split reconciliation, percentage basis points, weighted shares, heap reorder after partial payment, and minimal-transfer guarantees for multi-creditor/debtor groups.
- Pure-Go SQLite (
modernc.org/sqlite) — no cgo, so the build stays simple and cross-compilable. WAL mode + busy timeout keep concurrent QUIC streams from tripping over locks. go:embedeverything — templates and static assets ship inside the binary; deploy a single file.- Short-lived ECDSA cert — WebTransport's
serverCertificateHashesonly accepts ECDSA certs valid for <= 14 days, so a fresh 13-day cert is generated on every startup. No CA to install. - Manual
Alt-Svcheader — set directly in middleware to avoid a listener-registration race inquic-go'sSetQUICHeaders. - Integer money — floats are banned past the input boundary.
| Symptom | Cause / fix |
|---|---|
| Browser shows a cert warning | Expected — self-signed dev cert. Accept it once. |
proto: h2 instead of h3 |
First load is always HTTP/2; reload after the Alt-Svc header lands. |
| Live dot says "SSE fallback" | Your browser lacks WebTransport (Firefox/Safari). Updates still work via SSE. |
curl: option --http3 ...not support |
System curl has no HTTP/3. Use go run ./cmd/h3check instead. |
| Port already in use | Another instance is running: pkill -f 'splitwise-quic -addr'. |
Built over QUIC, one penny at a time.