Capturing thousands of leads in a no-signal arena — without losing one.
Offline-first PWA
Coconaut (shootout.kawalec.pl)
2026
Solo Full-Stack
At a glance
An offline-first lead-capture PWA for a 3x3 basketball event — every save lands on the iPad first, then syncs to the server whenever the network allows.
01 — The problem
A normal form is a data shredder in a packed arena.
Event lead-capture happens in the worst possible network conditions: a sold-out sports hall, hundreds of phones fighting for the same cell tower, and venue WiFi that folds the moment the doors open. A standard web form that POSTs on submit is exactly wrong for this — the request hangs, the hostess can’t wait, she moves to the next person, and the lead is gone before anyone notices.
Now multiply that by 10–20 iPads hitting the same endpoint, over an event week, with a target of thousands of leads. “Mostly works” isn’t a strategy — every dropped submission is a real person who consented to be contacted and will never get the follow-up. The cost of a lost lead is asymmetric: it’s not a retry, it’s a permanent miss.
Coconaut needed something that behaves like a native app, not a web page: instant on tap, local-first, and completely indifferent to whether there’s a connection at that exact second. The network should be an optimisation, never a dependency.
02 — The vision
Save first, sync later. — the device is the database, for as long as it has to be.
Three principles drove every decision:
- Offline-first, always. The save never depends on the network. A lead is written to the iPad the instant the hostess hits “Zapisz”. Reaching the server is a separate, background concern — not a precondition for success.
- Zero data loss. Between “saved on the iPad” and “stored in PostgreSQL” there must be no gap a lead can fall through. Idempotent uploads, server-side dedup and versioned device backups close every crack, including the browser wiping its own storage.
- Frictionless for the hostess. One screen, four fields, three RODO consents, a “Zapisz” that always succeeds. No login per lead, no spinner of doom, no “please try again”. The person at the booth never waits on a network.
03 — Who it’s for
Built for everyone who touches a lead between the booth and the CRM.
Event hostess
Works the floor on an iPad, often with no signal. Needs a form that saves instantly and never blocks her on the next person in the queue.
Event organizer
Owns the numbers. Wants every lead accounted for and a clean export at the end of the day — not a postmortem about what got lost in the hall.
Marketing / CRM
Consumes the leads afterwards for the mailing. Needs deduplicated emails with explicit RODO consents attached to each one.
Participant
The person at the booth. Gives their data once, accepts the terms, and expects it to “just work” in two taps — signal or no signal.
04 — The architecture
One inversion: for the duration of a save, the iPad is the source of truth.
A lead is validated and written to IndexedDB first; a separate sync engine drains that local queue to a Payload CMS API whenever the network allows. Every layer is built around that idea — React Hook Form + Zod at the edge, a Dexie status machine on the device, an idempotent /api/leads/sync endpoint on the server, and PostgreSQL as the final store. A Serwist Service Worker keeps the whole thing installable and offline; a backup endpoint guards against the browser evicting its own data.
05 — Technical challenges
Five problems that turned into design decisions.
Offline-first sync with nothing lost in the gap
Problem. 10–20 iPads in a hall with no reliable internet. Each lead has to be saved instantly, regardless of connectivity, then reach the server without ever falling through a crack. Websockets and naive HTTP polling both collapse in this environment.
Solution. Every lead is written to IndexedDB (Dexie) with an explicit status — pending, syncing, synced, failed. A custom sync engine drains the queue on five triggers (instant best-effort, 60s polling, tab visibility, online event, manual). An inflight-promise guard means a second sync waits on the first instead of duplicating the request; offline simply returns an empty summary and leaves statuses untouched. Network errors retry forever; only a structural failure (bad data) is marked failed. The local queue, not the network, is the source of durability.
Idempotent batch upload — no duplicates on retry
Problem. The network is flaky, so the app sends a batch, times out, and resends. The server sees the same lead twice. Classic UPDATE ... WHERE id = X can’t help — on the client the database id doesn’t exist yet.
Solution. Each lead generates a clientId (crypto.randomUUID()) on the device, before it ever leaves. /api/leads/sync checks WHERE clientId = X first and returns { status: 'duplicate', serverId } instead of inserting; a UNIQUE index on clientId in PostgreSQL enforces it at the storage layer. Tap “sync” five times and exactly one row is created — the other four return the existing serverId. Idempotency is a database constraint, not a hope.
Two-layer email dedup with race tolerance
Problem. Email is the one column the business actually mails to, so duplicates hurt. A participant can re-register from the same device (easy — clientId catches it) or from two devices at once, both offline, both syncing at the same second (a genuine race).
Solution. A preflight check — GET /api/leads/check-email — blocks a duplicate before it’s even saved when online; offline, it’s skipped by design. At sync time the server does a second check and returns duplicate_email with the existing serverId rather than creating a row. The narrow two-devices-offline-same-second race is consciously accepted at event scale and swept up post-event with a SELECT DISTINCT ON (LOWER(email)). Defence in depth where it’s cheap, a documented trade-off where it isn’t.
PWA on iOS without the storage hazard
Problem. iOS Safari can evict IndexedDB after a few days of inactivity, doesn’t support Background Sync, and a stale service-worker chunk can poison the shell. On an event device holding hundreds of uncaptured leads, that’s a disaster.
Solution. Mandatory PWA install (display: standalone) earns a bigger, more durable storage budget. Serwist cache strategy is deliberate per route: NetworkOnly for /api/* so saves are never served stale, StaleWhileRevalidate for the shell, CacheFirst for fingerprinted assets. Every few minutes each device ships its entire local DB as a JSON snapshot to a backup endpoint, keeping the last 8 versions per device — so even if iOS wipes storage, the leads survive server-side. Sync runs foreground-only, on a timer plus visibility and online events.
Validation without duplicating the rules
Problem. The form needs instant client-side validation and server-side validation for integrity — without maintaining two copies of the rules that drift apart, and while enforcing three separate RODO consents that all default to unchecked.
Solution. One leadInputSchema in Zod, imported by both sides. The client runs it through React Hook Form for inline errors; the server runs safeParse on every item in the batch and returns invalid with a reason on failure. The required consent uses a refine(v => v === true) so a false default still validates correctly, and a “Zaznacz wszystkie zgody” master checkbox toggles all three at once. Same rules, both ends, zero drift.
06 — The workflow
Fill. Save. Sync in the background.
For the hostess it’s two taps: type the lead, hit Zapisz. Behind that button the form validates against the Zod schema, writes the lead to IndexedDB with a pending status and a client-generated UUID, and fires a best-effort sync immediately. With signal, the lead reaches Postgres in seconds; without it, it sits safely in the local queue and the background engine retries on a timer, on tab focus, and the moment the network returns — no action required from anyone at the booth.
07 — Feature highlights
What ships on every iPad.
Each piece exists to make one sentence true in a real arena: capture the lead, never lose it.
+ Live sync status
A green/red indicator (SyncStatusDots) tells the hostess at a glance whether everything’s uploaded or still queued locally. “Wszystko zsynchronizowane” means it’s safe to move on.
+ Versioned device backups
Every few minutes each iPad ships its full local DB as a JSON snapshot to a backup endpoint, keeping the last 8 per device — so even if iOS evicts IndexedDB, the leads survive on the server.
08 — Stack
Picked for offline durability, one runtime, and zero ops drama.
Every tool earned its place by making “never lose a lead” easier to guarantee.
UNIQUE index on clientId makes idempotency a constraint, not a hope.09 — Results
Live, offline-proof, zero-loss by design.
Coconaut Shootout runs in production at shootout.kawalec.pl. Built in four days across 36 commits, it turns a hostile-network arena into a reliable lead-capture floor: every save is local-first, every upload is idempotent, every device backs itself up.
Like what you see?
Let’s build the next one.
From a blank page to a working product — offline-first PWAs, AI, automation, full-stack engineering. Get in touch and let’s talk about your idea.