Jeffrey JohnsonRSS

Puber: Built for the Long Miles

By Jeffrey Michael JohnsonPublished: May 12, 20269 min read

Puber is an ultramarathon support app. It connects 100-mile trail runners with pacers (who run alongside them through overnight mountain segments) and crew (who manage aid stations, gear, nutrition, medical), plus a per-race community board for rides, lodging, drop bags, and last-minute replacements.

The name is deliberate. A brand-reclamation play. In the ultra world, "pacer" and "Uber" are the two nouns that describe what this app does. Puber is the portmanteau. The plan is to plant its meaning in the running world so thoroughly that the other connotation becomes the second thing you think of.

The problem

The ultramarathon world runs on informal networks to find pacers and crew. Facebook groups, group texts, word of mouth. That system breaks exactly when it matters: a crew member drops out two days before the race, a runner needs a night-segment pacer in a town they've never been to, a solo woman runner wants a vetted pacer rather than a stranger from a forum.

The informal system works until it doesn't. And when it doesn't, you're at mile 80 at 3am with no one coming.

Puber app icon

What's built

The whole thing is built end-to-end, running locally on the iOS simulator and a clean supabase db reset. It is not deployed.

The mobile app

Expo SDK 54, React Native 0.81, Expo Router 6. State management with Zustand and TanStack Query. Animations with react-native-reanimated 4. Mapping with Mapbox, supercluster clustering, and GPX-to-GeoJSON parsing. MMKV and expo-file-system for an offline "Race Kit": race rules, aid-station data, and map tiles cached for zero-cell-service canyons. Push notifications via expo-notifications. Auth through Apple, Google, and Supabase. A 9-step onboarding flow with UltraSignup race-history import. Stripe Identity selfie-liveness verification, env-gated for development.

The backend

Supabase Postgres with roughly 25 migrations. RLS on every single table.

Note

That "every single table" qualifier earned itself. During the build, I hit a recursive-policy-evaluation bug where RLS policies on one table referenced a function that queried another RLS-protected table, creating an infinite loop. The fix required SECURITY DEFINER helpers to break the cycle. It's the kind of bug that only surfaces under real policy coverage, which is why skipping RLS "for now" is a trap: you never go back and add it, and if you do, you find bugs like this one that reshape your schema.

Fourteen Deno edge functions. Five pg_cron jobs: a check-in-escalation sweep every 3 minutes, a race-rule staleness flagger, a Race Kit manifest rebuild, timed-ban expiry, and hard-delete of deactivated accounts. Six realtime-published tables. Four RLS'd storage buckets.

A match state machine implemented as a Postgres BEFORE-UPDATE trigger: requested, pending, rules_acknowledged, confirmed, active, completed, with side-exits to cancelled, cancelled_by_admin, and expired. Plus a block-auto-cancel trigger that immediately terminates a match if one user blocks the other.

Seeded with 10 marquee 100-milers, 46 source-cited rules, 84 aid stations.

The ops console

Next.js 15, shadcn/ui, TanStack Query and TanStack Table. Supabase Auth with TOTP and WebAuthn passkeys. A 7-role by 28-capability RBAC. Append-only audit logging. Seventeen route handlers. A race-data editor that requires a source citation on every single race rule: a mandatory accuracy contract, because runners trust those rules at 3am in a canyon with no signal.

Safety as architecture

Gender-preference matching. Scheduled check-ins with escalation. "Tell a friend" live-location sharing. DV-safety options. Women's safety is a design requirement, not a feature flag. The check-in escalation sweep runs every 3 minutes via pg_cron: if a runner or pacer misses a scheduled check-in, the system escalates to their emergency contact automatically. That's not a notification preference. That's a cron job in the database.

The brand

Puber brand system board

The logomark is a stylized jackrabbit mid-bound. Lean, athletic, ears swept back. The hare is the pacer who sets the pace. Confident and a little wild. Not cute, not cartoon.

Palette: Elevation Blue #007AFF, Summit Navy #0B1D2A, Trail Orange #FF6B00, Peak Yellow #FFC107, Alpine Mint #00D1B2.

Type: Inter for the UI, a heavy geometric display grotesque for the wordmark, SpaceMono for splits and race data.

Voice: rugged but refined. Calm confidence. Dawn-on-the-trail. Soul sport. Comps for energy, not look: Patagonia, Cotopaxi, On Running, Tracksmith, Black Diamond.

The brand system is documented in full at 87n1.com/design/puber-brand-system.

What's left

This is the honest part. "Built" is not "shipped," and the gap between them on a verified-identity safety app is exactly the legal and ops work that's still ahead.

What's left before launch: App Store screenshots. A designer-redrawn SVG of the logomark (the current marks are AI-generated rasters). iOS 18 tinted icon variants. Production Supabase instance plus a Mapbox download token plus Apple, Stripe, and Twilio credentials. EAS config. Legal review. App Store submission.

The offline Race Kit is architecturally done, but caching real map tiles for real canyons is the part that needs field testing. The difference between "it works on wifi" and "it works in a canyon outside Silverton at mile 62" is the difference between a demo and a product.

Target: iOS and Android, for the 2026 marquee 100-mile season. Western States, Hardrock, Leadville, Cocodona, Bear, Wasatch, and the rest.

The Puber case study covers the full architecture.

What almost broke it

The recursive RLS bug was the closest call. Postgres doesn't warn you when a policy's subquery hits another policy-protected table. It just recurses until it times out. The fix, SECURITY DEFINER helper functions that bypass RLS for internal lookups, is clean, but discovering the bug required real RLS coverage on every table. If I'd left even one table unprotected "for now," I'd have shipped the app thinking the policies worked, and they wouldn't have.

The other near-miss is subtler. The match state machine has nine states and two side-exit triggers. Getting the state transitions right in a Postgres trigger is finicky work: you have to handle every possible previous_state-to-new_state pair, and the block-auto-cancel trigger has to fire before the normal transition logic. I got the ordering wrong twice. Both times the bug was silent: the match just stayed in a stale state until someone noticed. Silent failures in safety-critical state machines are the kind of thing that keeps you up.

The distance between "it works" and "they trust it"

Here's the thing I keep thinking about. The simulator runs the app. The seed data populates the map. The match flow works: request, acknowledge rules, confirm, check in, complete. On my machine, it's a working product.

But "a stranger trusts it at mile 80 at 3am" is a different bar entirely. That trust requires verified identity, which requires Stripe Identity in production, which requires legal review. It requires the check-in escalation to actually reach a real emergency contact, which requires Twilio credentials and tested SMS delivery in canyon terrain. It requires the race rules to be accurate, which requires the source-citation contract in the ops console to be enforced by a human who's read the actual race handbook.

The distance between "it works on my simulator" and "a runner trusts it with their safety" is the whole job. Not the code. Not the stack. The operational, legal, and human infrastructure that makes the code trustworthy. That's what's left, and it's the part that doesn't compress.

Occasional updates when I publish. No spam.