Six objects. Each one exists exactly once. No server enforces it.
React 19 · Go 1.26 compiled to WebAssembly · IndexedDB · Web Locks: one static bundle on GitHub Pages.
This is intentionally an engineering demo, not a recommended production architecture for e-commerce. The server was removed on purpose, to find out what is left to enforce an invariant with when the only runtime you get is the browser.
- Concurrency safety: competing reservations across tabs cannot corrupt inventory.
- Explicit domain modelling: the business rules live in a deterministic Go state machine, not in components.
- Trust boundaries: a browser payment return is never treated as proof of payment.
- Production discipline: CI, cross-browser E2E, race detection, performance budgets, deployment validation.
Every object in the catalogue is edition 1/1. Selling the same one twice is the failure this project exists to prevent, and it is a concurrency problem, not a UI problem. A normal shop settles it with a transaction and a unique constraint in a database.
There is no database here. No API, no Lambda, no edge function: the whole shop is a static bundle served by GitHub Pages. So the interesting question is what you have left to enforce an invariant with when the only runtime you get is the browser.
The answer this project commits to: a pure state machine written in Go, a Web Lock to serialise writers, and an optimistic version check as the fallback. The domain never runs in TypeScript.
Open the same object twice and reserve it in both. One reservation is created. The other is refused
by the engine, not by a disabled button, not by a debounce, but by a domain rule that returns
OBJECT_UNAVAILABLE and refuses to write.
sequenceDiagram
autonumber
participant A as Tab A
participant B as Tab B
participant L as Web Lock
participant E as Go engine (WASM)
participant D as IndexedDB
par The same object, the same instant
A->>L: request, exclusive
and
B->>L: request, exclusive
end
L-->>A: granted
A->>D: read the world, version 1
A->>E: RESERVE_OBJECT rock-001
E-->>A: AVAILABLE becomes RESERVED, order created
A->>D: write the world, version 2
A->>L: release
L-->>B: granted
B->>D: read the world, version 2
B->>E: RESERVE_OBJECT rock-001
E-->>B: OBJECT_UNAVAILABLE
Note over B,D: Nothing is written. The tab refreshes from<br/>storage and shows the object as held.
Three mechanisms carry that guarantee, and each one covers a different failure:
- A Web Lock serialises the writers.
navigator.locks.request('finite-goods:world', { mode: 'exclusive' })wraps the whole read → decide → write sequence, so two tabs cannot interleave inside it. - An optimistic version check catches what the lock cannot. The write re-reads the stored world inside the IndexedDB transaction and aborts it unless the version is still the one that was read. That is what protects the demo in a browser without Web Locks, and it is also the guard against a lock that was never held in the first place.
- Event IDs make retries harmless. Every command carries a UUID. The engine ignores a command whose event ID it has already recorded, so a replayed webhook, a double-click, or a retry after a refresh cannot sell an object twice.
The losing tab then hears about it without polling: BroadcastChannel announces the change, and a
localStorage write is the fallback for browsers that ignore it.
flowchart TB
ui["React 19<br/>UI, 41-line router, pre-rendered HTML"]
store["demoStore<br/>builds the command, maps the failures"]
subgraph locked["Inside the exclusive Web Lock, one writer at a time"]
direction TB
read[("IndexedDB<br/>read the current world")]
worker["Web Worker"]
engine["Go compiled to WebAssembly<br/>Apply(World, Command)"]
write[("IndexedDB<br/>write, only if the version is unchanged")]
read -->|"world and command"| worker
worker --> engine
engine -->|"a new world, or a typed domain error"| write
end
channel(["BroadcastChannel<br/>every other tab refreshes"])
ui -->|"reserve(objectId, customer)"| store
store --> read
write --> channel
channel --> ui
classDef go fill:#dfe4d8,stroke:#7d8a6b,color:#1d2b16
classDef browser fill:#f6e2d7,stroke:#c8794a,color:#5c2f10
class engine,worker go
class read,write,channel browser
TypeScript orchestrates. It never decides. Everything green is Go; the orange nodes and the boxed critical section are browser primitives doing the job a server usually does.
Six commands, six order states, and one rule that governs all of them: only a verified payment event may turn held inventory into sold inventory.
stateDiagram-v2
[*] --> RESERVED: RESERVE_OBJECT
RESERVED --> PAYMENT_PENDING: BEGIN_PAYMENT
PAYMENT_PENDING --> UNVERIFIED_RETURN: RETURN_FROM_CHECKOUT
PAYMENT_PENDING --> PAID: CONFIRM_PAYMENT
UNVERIFIED_RETURN --> PAID: CONFIRM_PAYMENT
RESERVED --> EXPIRED: EXPIRE_RESERVATION
PAYMENT_PENDING --> EXPIRED: EXPIRE_RESERVATION
UNVERIFIED_RETURN --> EXPIRED: EXPIRE_RESERVATION
PAID --> REFUNDED: REFUND_ORDER
EXPIRED --> [*]
REFUNDED --> [*]
PAID --> [*]
The object follows along, and its status is the part a customer actually sees:
| Order reaches | The object becomes | Because |
|---|---|---|
RESERVED |
RESERVED |
a five-minute hold, exclusive to one order |
PAYMENT_PENDING · UNVERIFIED_RETURN |
RESERVED |
still held: neither state proves anything was paid |
PAID |
SOLD |
the only transition that consumes the object |
EXPIRED · REFUNDED |
AVAILABLE |
the hold is released and the object returns to stock |
A late CONFIRM_PAYMENT is rejected rather than honoured: if the hold elapsed, the command comes
back as RESERVATION_EXPIRED and the object stays available for whoever asks next.
The checkout leaves for a Stripe Payment Link in test mode and comes back. That redirect is user-controlled (anyone can type the return URL), so this demo treats it as what it is: a browser signal, and nothing more.
The order lands in an explicit UNVERIFIED_RETURN state and the object stays held, never sold.
Only CONFIRM_PAYMENT (the path a real webhook consumer would own) is allowed to consume
inventory, and the demo makes you press that button yourself so the boundary stays visible.
A static site cannot receive a webhook. Rather than hide that, the interface states it.
The back office is not a mock screen: it reads the same world the shop writes to, and shows the append-only event log with the identifiers that make the whole thing replay-safe.
Why Go compiled to WebAssembly? Not because this is how I would build a production storefront, but because the project explores whether the exact same deterministic domain engine can run in the browser and be race-tested outside the UI layer. It can, and the cost is measurable below.
The engine is pure, and that is the whole point. Apply(World, Command) → Result reads no clock,
touches no storage and generates no identifiers: the command carries now and its event ID. So the
exact code the browser runs is also the code go test -race exercises, and the browser is free to
retry a command without reasoning about side effects.
The 3.4 MB engine is never on the critical path, and that is what pays for it. The worker is
created lazily, on the first command. Loading the homepage requests the HTML, one JS bundle and the
object images: no engine.wasm, no stylesheet request at all. The first command then costs
~900 ms and every one after it ~20 ms; Go's WebAssembly runtime is what makes the first number
large, nothing in the domain logic does. It is a real trade-off, taken knowingly: the demo buys a
testable, race-checked state machine and pays for it with one deferred second, once, when someone
actually reserves something.
Two guards, not one. Web Locks serialise the writers; the IndexedDB version check aborts the
transaction if the world moved underneath. The second one is not redundant: it is what holds the
line in a browser without Web Locks, and it is why the store can map a lost race to STATE_CHANGED
and refresh instead of corrupting anything.
A browser redirect is user-controlled, so it is never proof. A static host makes that distinction unavoidable; plenty of real checkouts blur it anyway, by treating the return URL as a receipt.
Lighthouse 12.6 via @lhci/cli, median of three runs, against the production build served by
pnpm preview. Both profiles are asserted at minScore: 1 in CI, so a regression fails the
pipeline instead of being noticed later.
| Performance | Accessibility | Best practices | SEO | |
|---|---|---|---|---|
| Desktop | 100 | 100 | 100 | 100 |
| Mobile (throttled) | 100 | 100 | 100 | 100 |
Desktop: FCP 0.3 s · LCP 0.4 s · TBT 0 ms · CLS 0. Throttled mobile: FCP 1.1 s · LCP 1.7 s · TBT 0 ms · CLS 0.
What the browser actually downloads, gzipped, as GitHub Pages serves it:
| Request | On the wire | When |
|---|---|---|
index.html: pre-rendered homepage with the entire stylesheet inlined |
9.8 kB | first paint, and it needs nothing else |
index-*.js: React and the whole application |
~70 kB | after the copy is already on screen |
engine.wasm: the Go domain engine |
~980 kB | on the first command, never before |
The engine is 3.4 MB uncompressed. It is also the single most expensive thing in the project, which is exactly why nothing on the critical path touches it. See Design decisions and trade-offs.
Timings, Chromium against the local production build, median of three runs (pnpm benchmark):
| Path | Time |
|---|---|
First command: worker start, 3.4 MB module instantiated, then RESERVE_OBJECT |
~900 ms |
| Two further commands on the warm worker: lock → read → engine → write, twice | ~40 ms |
| Homepage first contentful paint, unthrottled | 44 ms |
Bundle budgets, enforced by size-limit in CI on the built output:
| Asset | Brotlied | Budget |
|---|---|---|
| Initial JavaScript | 61.65 kB | 130 kB |
| Styles | 6.82 kB | 12 kB |
Reproduce any of it:
pnpm build && pnpm lighthouse # mobile; lighthouse:desktop for the other profile
pnpm benchmark # the three timings above, median of three runs
pnpm size # the budgetsNeeds Node.js 24, pnpm 10 and Go 1.26.
pnpm install # also installs the git hooks
pnpm dev # localhost:3000, compiles the Go engine to WebAssembly firstpnpm check # lint · types · dead code · unit tests · go test -race · production build
pnpm e2e # Playwright, five browser projectsTouching only the engine? pnpm wasm rebuilds it on its own, and pnpm test:go runs the domain
tests with the race detector.
Do not ship vite build alone. The build chain pre-renders the homepage, writes an HTML shell for
every route, inlines the stylesheet, emits 404.html, robots.txt and .nojekyll, then asserts the
output is complete. scripts/postbuild.mjs and scripts/check-build.mjs do that work.
Set VITE_STRIPE_PAYMENT_LINK to a Stripe Payment Link in test mode and the checkout leaves for the
real hosted page. Without it, the local return page demonstrates the same trust boundary. Either way
no real payment is processed and nothing is stored outside the browser.
MIT. See LICENSE.


