Loading
Case study
PWA · lead capture · offline-first

Capturing thousands of leads in a no-signal arena — without losing one.

Category
Offline-first PWA
Client
Coconaut (shootout.kawalec.pl)
Years
2026
Role
Solo Full-Stack
Coconaut Shootout Challenge lead-capture app on an iPad — green Coconaut basketball jersey and SHOOTOUT CHALLENGE headline
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.

2–10k
Leads captured across an event week
10–20
iPads writing to one backend at the same time
<0.5%
Target lead-loss rate, IndexedDB → PostgreSQL
100%
Offline-capable — every save works with zero signal
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.

Coconaut Shootout consent screen on an iPad — RODO consents with green checkmarks and a green Zapisz button
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.

Lead form
React Hook Form 7 · Zod · 3 RODO consents
Service Worker
Serwist 9 · PWA standalone · offline shell
IndexedDB queue (Dexie 4)
status machine: pending → syncing → synced / failed · clientId UUID per lead
Sync engine
foreground polling 60s · visibility + online triggers · batch 100 · inflight dedup
Payload CMS 3 API
/api/leads/sync · check-email · backup
Panel auth
jose JWT · constant-time compare
PostgreSQL 16
UNIQUE clientId · Docker Compose · Caddy · Hetzner VPS
05 — Technical challenges

Five problems that turned into design decisions.

01

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.

02

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.

03

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.

04

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.

05

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.

Coconaut Shootout lead-capture form on an iPad, flat-lay — name fields, email, RODO consents and a green Zapisz button
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.

Coconaut Shootout lead form on an iPad — name, surname and email fields with green checkmarks
+ 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.

Layer
Tech
Why
Frontend
Next.js 16 (App Router) + React 19 + TS 6
One framework for the form, the API routes and the PWA shell. App Router + Server Components, Turbopack in dev.
Form & validation
React Hook Form 7 + Zod 3
One schema validates identically on client and server. Instant inline errors, no duplicated rules.
Offline store
Dexie.js 4 / IndexedDB
Local queue with a status machine. The lead lives on the device first; sync is a background job.
PWA
Serwist 9 (Service Worker)
Standalone install, offline shell, NetworkOnly for APIs so a save is never served from stale cache.
Backend
Payload CMS 3 + Next API routes
Leads collection and an admin UI for free. Sync, check-email and backup endpoints alongside it.
Database
PostgreSQL 16
The source of truth. A UNIQUE index on clientId makes idempotency a constraint, not a hope.
Panel auth
jose (JWT, HMAC)
Hostess/admin panel login with a constant-time password compare and a per-deployment password.
Deploy
Docker Compose + Caddy (Hetzner VPS)
Automatic HTTPS, one-command deploy, isolated as its own service on the box.
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.

Coconaut Shootout PWA on an iPhone — full lead-capture app with SHOOTOUT CHALLENGE hero and the form below
<0.5% loss
Target lead-loss rate from the iPad through to PostgreSQL.
10–20 iPads
Writing to one backend at once, each with its own offline queue.
4 days
From empty repo to deployed PWA — 36 commits, ~3,800 lines.
100% offline
Every lead saves with no signal; sync catches up on its own.

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.