Performance & global scale (Africa-on-3G)
The Africa-on-3G constraint
Lead with the answer. No, not as written. A grassroots Sport Head turning up to the December 2026 fight night at Freetown National Stadium on a Transsion-class budget Android, on a single congested cell, with a 50 MB nightly data budget, cannot reliably finish the journey today. The failure modes are bundle weight, sequential image loads, an EU-Ireland database 5,000 km away, and zero offline tolerance. Each is fixable; none is fixed.
The market we are designing for, not the market we wish we had:
- Sierra Leone. Mobile penetration ~50%, internet usage ~25% of population. First 5G launch is announced but won't be at the stadium in December. Real-world tower behaviour is 3G fallback under load.
- Nigeria. Airtel claims 99% site-level 4G coverage; experienced median speeds are far lower, especially in stadium-density crowds. 1 GB costs ~$0.71, ~1.7% of monthly income — under the ITU 2% affordability bar but not by much.
- Ghana. 4G penetration ~15% baseline, target 80% by ~2027 — meaning today, the median connection is still 3G.
- Data cost. Sub-Saharan Africa averages 2.4% of monthly income per GB — above the ITU bar. Sierra Leone is ~9.9% on the cheapest plans, having come down from 25.9%. A 5 MB registration session is not trivially "free" — it's a meal of jollof's worth of data.
- Device. Transsion (Tecno, Infinix, itel) owns ~48% of the African smartphone market. The $100–$199 segment is 42% of shipments. itel A100C in Nigeria ships with 2 GB RAM + 64 GB storage. Entry-level RAM is regressing toward 4 GB in 2026 due to the global DRAM crunch. Chrome on 2 GB RAM is the design target.
Stadium-scale events compound the problem: a single macro cell hits its sector throughput ceiling well before 5,000 phones in the same bowl all want to upload PNG bytes. The launch venue isn't "3G under good conditions" — it's "EDGE-ish, contended, with 30-second connectivity gaps when the broadcaster fires up". That's our worst-case canonical user.
Bundle weight audit
What sporthead-id actually ships today. The Next.js 15 static export (apps/sporthead-id/next.config.mjs:3) gives us no SSR escape hatch — every byte is shipped to the client. Estimates are minified+gzipped over the wire.
| Payload | Wire (gzip) | % of 1 MB budget | Status |
|---|---|---|---|
| Next.js 15 framework runtime (App Router, hydration) | ~85–95 KB | ~9% | essential |
| React 19 + React DOM | ~45 KB | ~4.5% | essential |
| Tailwind 4 CSS (preflight + tokens + utilities used) | ~15–25 KB | ~2% | essential, well-tuned |
| @supabase/supabase-js ^2.105.1 (full client) | ~110–130 KB | ~12% | over-shipped — we use postgrest + auth only |
App code (app/ + lib/ + composer, ~3500 LOC TS) | ~60–80 KB | ~7% | essential, could split |
Per-route chunks (/start composer, /welcome exporter) | ~30–50 KB | ~4% | code-split already |
| JS+CSS total over the wire | ~350–425 KB | ~38% | acceptable, not lean |
| Trait PNGs (99 files in Supabase Storage, raw) | 37 MB raw, ~390 KB avg, max 1.8 MB | n/a — fetched not bundled | unbounded, untranscoded |
| A composed avatar (10 layers, avg) | ~3.9 MB at default sizing | n/a | launch-blocker on 3G |
The JS bundle isn't the problem. The traits are. 99 PNGs totalling 37 MB raw. A single fully-composed avatar pulls ~10 layers, averaging ~3.9 MB total. At a Freetown median throughput of ~150–400 Kbps under contention, that is 80–200 seconds of "the screen is loading" before the composer is interactive.
The Supabase JS SDK at ~120 KB gzipped is also a soft offender. We use .from().select(), .upsert(), .rpc(), and auth.signUp. The full client drags in realtime, storage upload, edge functions — none used. Replacing with hand-rolled fetch against PostgREST + GoTrue saves ~80 KB.
The avatar composer is the perf hotspot
The single biggest perf bug in the codebase sits at apps/sporthead-id/lib/avatar-export.ts:53. One line, costs everything.
The bug
for (const { category, file, yOffset } of layers) {
const img = await loadImage(traitUrl(category, file));
ctx.drawImage(img, offsetX, offsetY + yOffset, ...);
}
Sequential await per layer. Ten layers × (RTT + transfer) = 10× round trips serialised. On Freetown 3G with ~300 ms RTT and ~400 KB avg payload, that is 3 s of RTT alone plus ~8 s of transfer, end-to-end 10–12 s before the canvas paints. On stadium-contended cellular, double it.
What Promise.all would actually buy you
Parallelising the loads removes the RTT serialisation, taking the floor from ~3 s to ~300 ms. But the link is bandwidth-limited, not concurrency-limited — ten parallel TCP streams compete for the same 200 Kbps pipe. Net win at the venue: ~30–40%, not 10×. Worth doing in an afternoon. Not the real fix.
The real fixes, scored:
| Option | Dev cost | 3G perf gain | Fidelity loss | Verdict |
|---|---|---|---|---|
| Promise.all parallel loads | 1 hour | 30–40% | none | ship today |
AVIF/WebP transcode via planned traits.sporthead.id CDN | 2 days | 60–70% byte reduction; AVIF avg 50% smaller than PNG for flat-colour art | imperceptible | ship before launch |
| Sprite-sheet — pack all 99 traits into one atlas, slice in JS | 3 days | One-shot load, cacheable, ~5 MB total transcoded; sliced via drawImage(src, sx, sy, sw, sh, ...). All subsequent composes zero-network. | none | ship before launch |
| Server-side composition (CF Worker + Photon WASM) | 1 week | One round trip, ~80 KB AVIF for final avatar vs 3.9 MB of inputs | none | strongest answer; biggest architectural commit |
| Skeleton avatar + progressive layer reveal | 2 days | TTFP drops to ~500 ms, total transfer unchanged | UX regression — preview flickers in | good with sprite-sheet, redundant with server-side |
| Ditch composable PNGs entirely — canonical shapes + colour theming | 2 months + design rework | ~95% byte reduction | founder identity feels generic; loses the composer promise | not on the table |
Recommended stack: Promise.all this week + sprite-sheet + AVIF behind traits.sporthead.id before launch. Server-side composition is the right long-term play but the launch can't wait. Cache headers: Cache-Control: public, max-age=31536000, immutable on content-hashed URLs.
Offline-first registration
The journey today is online-only and the failure mode is silent. Step 4 (handle) writes succeed locally to localStorage but the pending_profiles upsert in lib/pending.ts:120 fires-and-forgets. If the network drops, only a console warning logs; the user has no idea their handle hasn't synced.
What survives a 30-second network blip today
localStorage state survives — locale, country, avatar traits, handle, pending_id are all written before each network attempt. When connectivity returns and the user moves to the next step, syncPendingProfile is called and the upsert lands. This works. What doesn't: no visible sync indicator, no retry UI, and step 5 (the actual auth.signUp) has no offline queue.
What we need
- Service Worker caching the JS+CSS bundle and sprite-sheet. Cache-first for shell, network-first with stale fallback for registry.
- Background Sync API for
pending_profilesupserts — queued in IndexedDB if offline, replayed on reconnect. - Visible sync indicator. A small pill ("saved" / "saving" / "queued — will sync") next to step header. People panic less when they can see the queue.
- Sign-up offline mode. Defer the auth call to step 6 with a "complete sign-up" button that's enabled when online. Step 5 becomes "save your details locally"; step 6 becomes "go live".
Feature-phone fallback
PIN-as-primary auth is the right call for the population, but the rest of sporthead-id is a JS bundle that won't run on a $20 KaiOS-or-worse handset. What's the realistic fallback?
| Option | Reach | Cost | Verdict |
|---|---|---|---|
| USSD via Africa's Talking | Universal — works on any GSM phone, no data needed | Initial deposit ~$100–$500/country, per-session charges every 20s post-paid. At ~$0.02/session × 5,000 = $100 of USSD spend at launch. | realistic for handle + PIN + country; no avatar |
| SMS-only registration (phone + PIN + handle by reply) | Universal | ~$0.01–0.02/SMS regional. 4–5 messages × 5,000 = ~$250 plus inbound number rental. | workable, less guided than USSD |
| Local steward — federation rep does bulk signup on a tablet | However many warm bodies SLFA fields | ~$0 software cost; political cost in trust/data integrity. Steward sees the PIN. | good safety valve, bad primary — PIN exposure |
| Native Android lite app (~2 MB APK, no composer) | Smartphone-only | ~3 weeks eng, store review | not in scope for December |
Opinion: USSD via Africa's Talking is the only honest answer for genuine feature-phone reach. Build a shortcode menu (*348# or similar) capturing handle + PIN + country + district + opt-in to be photographed for an avatar at the venue. Data syncs into pending_profiles via webhook to a small CF Worker. Avatar composed post-event by a steward or auto-generated from a default shape with country-flag colour theming.
Regional Supabase strategy
Database in eu-west-1 (Dublin). Freetown to Dublin: ~5,000 km — ~80–150 ms TCP RTT best case, 200–300 ms over congested mobile. Each PostgREST query is ~2 RTT; each new TLS handshake is ~3 RTT cold. Five journey steps × ~3 calls × 200ms = ~3–4.5 s of pure network wait.
Supabase does not offer Africa
As of May 2026, Supabase has no managed region in Africa. The community discussion has been open for over a year. AWS Cape Town exists; Supabase has declined to enable it. Lagos isn't on AWS at all. This isn't going to change before December.
What we can do instead
- Read replicas. Supabase Pro+ supports read-only replicas in additional AWS regions. None in Africa — best we get is
eu-west-2(London) orme-south-1(Bahrain — worse). Read replicas help São Paulo (sa-east-1, ~150 ms RTT) materially. They don't help West Africa. - Cloudflare PoP + KV cache for read-heavy paths. Handle generator + trait registry are read-heavy and tolerant of staleness. Front with a CF Worker that caches in KV (PoP-local) with 5-minute TTL. Freetown's nearest CF PoP is Lagos or Accra — ~20–50 ms RTT. Most impactful single infra change.
- D1 mirror. Cloudflare D1 has regional placement. Mirror
public_profilesand handle vocabulary for read paths. Writes still go to Dublin. Eventually-consistent. - Connection pooling. Confirm
NEXT_PUBLIC_SUPABASE_URLpoints at the pooler, not the direct DB. At 5k signups peaking 20–50 req/s, we'll exhaust defaultmax_connectionswithout it.
The hot RPC: generate_sporthead_handle
Currently called 5× in parallel per "shuffle" at lib/handle.ts:151. A user clicking shuffle three times before settling = 15 RPCs. Across 5,000 users averaging two shuffles = ~50,000 RPC calls in a 2h window. Cache per-locale vocabulary in a Worker; run random pick + uniqueness check at the edge against a D1 mirror. Dublin only sees the final "claim this one" write.
Image / trait CDN strategy
The planned traits.sporthead.id Cloudflare Worker is the single highest-leverage infra item before launch. lib/traits.ts:191 already isolates the URL builder, so the swap is one function — but the worker behind it doesn't exist yet.
Required behaviour
- Format negotiation. Honour
Acceptheader. AVIF capable, WebP fallback, PNG to ancient Chrome. - DPR-aware sizing. Most budget Android is DPR 2.0–2.625. Avatar renders at 800×800 logical = 1600–2100 physical. Serve
?w=360variant for picker chips, full res only for export. - Immutable caching. Content-hashed paths →
Cache-Control: public, max-age=31536000, immutable. First Freetown user pays cold-cache; rest get Lagos/Accra PoP cache in ~30 ms. - Origin failover. If Supabase Storage 5xxs, serve last-known-good cached body. Critical on launch night.
- Hot-bundle endpoint. A
/sprite/{locale}.avifroute returning pre-packed sprite-sheet. One request, ~5 MB AVIF for whole catalog, edge-cached forever.
Cost rough-math
CF Workers Paid: $5/mo flat + $0.50/M requests over 10M. 5,000 users × ~20 trait requests/signup = 100k requests over launch night. Trivially under included tier. Total marginal cost: ~$5/mo.
Image transformations are the gotcha. CF Images binding counts every Worker invocation as a transformation regardless of dedup — $0.50/1k transforms after 5k free. Naive use → 100k transforms = $47.50 for launch night. Solution: cache aggressively in caches.default + serve cached body without re-hitting binding. Bill stays under $20.
R2 as backing: $0.015/GB-month, zero egress to CF Workers. 37 MB of source traits = $0.0005/mo. Move canonical bucket from Supabase Storage to R2 — origin egress disappears.
The launch-night load profile
5,000 signups in ~2h. Avg ~0.7 req/s of signups; but signups aren't requests — they're sessions of ~30–50 Supabase round-trips. Real DB load: ~15–35 req/s sustained, with bursts to 80–150 req/s when the broadcaster cuts to a commercial break and 500 people pull out their phones.
Does the current plan survive this?
Free tier: hard no. 200 concurrent realtime, 500 MB DB, 50k MAU cap. We exhaust MAU on launch night.
Pro ($25/mo): survives quota-wise — 100k MAU, 8 GB DB, 500 realtime. Postgres connection limit is the bottleneck. Default max_connections on Pro: ~60 direct + ~200 via pooler. At 80–150 req/s through PgBouncer in transaction mode, OK if every query is <100 ms. Hot RPC (generate_sporthead_handle) is easily <50 ms. Risk is cold pool at peak burst.
Team ($599/mo): not needed if we cache read paths at edge.
Worst case: half the cohort signs up in 10 min after the main fight
2,500 sessions × ~40 round-trips = 100,000 requests in 600 seconds = ~165 req/s sustained, bursts to ~400 req/s. PgBouncer pool will bottleneck; auth.signUp is the most expensive single call and isn't poolable the same way. We will see 503 too many connections.
Mitigation: rate-shape the burst with a CF Worker queue in front. Cap to 50 req/s to Supabase; queue the rest with 5–30s "you're in line" UX. Combined with edge cache for trait registry / handle generator, sustained Dublin traffic drops ~60%. Tested, this is survivable on Pro.
Performance verdict
As-is: launch-blocker. With three weeks of focused work: ship-ready.
The codebase architecture is sound — URL builder isolated, journey state localStorage-first, the journey is anonymous through step 4. The failures are concentrated and known: (1) sequential image loads in lib/avatar-export.ts:53 compounded by raw PNGs at avg 390 KB and no edge CDN — fix with Promise.all + sprite-sheet + AVIF behind traits.sporthead.id; (2) every read hits Dublin from West Africa — fix with a Worker + KV cache for the trait registry and the generate_sporthead_handle RPC, dropping ~60% of Dublin traffic; (3) no offline tolerance — fix with a Service Worker shell cache + Background Sync queue for pending_profiles. Total infra spend for launch: ~$30–50/mo (Workers Paid + Supabase Pro + R2). Total engineering: ~3 weeks for one engineer, parallel-friendly. The feature-phone segment is a separate USSD project (~2 weeks + ~$200 launch-night spend) — right call to scope for v1.1.