SHOTid auditfeat/v2 · 38549fa · 2026-05-16
Performance · §06

Performance & global scale (Africa-on-3G)

Branchfeat/v2 Commit38549fa AudienceJonny, Liam, eng
01

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.

02

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.

PayloadWire (gzip)% of 1 MB budgetStatus
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 MBn/a — fetched not bundledunbounded, untranscoded
A composed avatar (10 layers, avg)~3.9 MB at default sizingn/alaunch-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.

03

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:

OptionDev cost3G perf gainFidelity lossVerdict
Promise.all parallel loads1 hour30–40%noneship today
AVIF/WebP transcode via planned traits.sporthead.id CDN2 days60–70% byte reduction; AVIF avg 50% smaller than PNG for flat-colour artimperceptibleship before launch
Sprite-sheet — pack all 99 traits into one atlas, slice in JS3 daysOne-shot load, cacheable, ~5 MB total transcoded; sliced via drawImage(src, sx, sy, sw, sh, ...). All subsequent composes zero-network.noneship before launch
Server-side composition (CF Worker + Photon WASM)1 weekOne round trip, ~80 KB AVIF for final avatar vs 3.9 MB of inputsnonestrongest answer; biggest architectural commit
Skeleton avatar + progressive layer reveal2 daysTTFP drops to ~500 ms, total transfer unchangedUX regression — preview flickers ingood with sprite-sheet, redundant with server-side
Ditch composable PNGs entirely — canonical shapes + colour theming2 months + design rework~95% byte reductionfounder identity feels generic; loses the composer promisenot 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.

04

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_profiles upserts — 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".
05

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?

OptionReachCostVerdict
USSD via Africa's TalkingUniversal — works on any GSM phone, no data neededInitial 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 tabletHowever 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 reviewnot 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.

06

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) or me-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_profiles and handle vocabulary for read paths. Writes still go to Dublin. Eventually-consistent.
  • Connection pooling. Confirm NEXT_PUBLIC_SUPABASE_URL points at the pooler, not the direct DB. At 5k signups peaking 20–50 req/s, we'll exhaust default max_connections without 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.

07

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 Accept header. 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=360 variant 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}.avif route 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.

08

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.

09

Performance verdict

Performance verdict · high confidence

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.