RoadSafe
A road-safety app for Pakistani highways, built for the moment the signal drops.
- Status
- In Development
- Updated
- August 1, 2026
Problem
A road-safety app is used precisely where connectivity is worst. On the highways between Pakistani cities — the ones with the landslides, the washed-out shoulders, the accidents — the bar is not "works on wifi." It's this has to work on a dead link, on a phone with the screen locked, in the ten seconds after something has gone wrong.
RoadSafe reports hazards, puts them on a map alongside live disaster feeds and historical landslide zones, broadcasts an SOS, records journeys in the background, and warns you when you're driving toward a known hazard. Every one of those flows had to survive the loss of the network, and that constraint — not the feature list — is what shaped the architecture.
Two hard requirements fell out of it: a write must never be lost because the signal was, and the app must never state something it doesn't know. On a safety map, "no hazards nearby" is a claim, and rendering it after a failed request is a lie with consequences.
Architecture
Two independent projects: an Expo / React Native client and an Express 5 + MongoDB API. The interesting part isn't the layering — it's the durability that hangs off the client.
Every keyed third party — Gemini, Cloudinary, OpenWeatherMap, LocationIQ, Overpass
— is reached through a proxy route, so no secret ever enters the shipped bundle.
Expo's EXPO_PUBLIC_* prefix inlines a value into the JS bundle at build time, which
makes "just put the key in the app" a permanent leak, not a shortcut. The two feeds
the client does call directly — GDACS and OpenFreeMap tiles — are keyless and
public, so proxying them would add a hop and buy nothing.
Backend routes state their whole chain explicitly, one line per route:
router.post('/', protect, defaultRateLimiter, mixedUpload, handleMulterError, validateCreateTrip, createTrip);Auth is JWT with HS256 pinned in jwt.verify — the token doesn't get to nominate
its own algorithm. Rate limiting is three tiers, and the middle one exists for money
rather than abuse: chatRateLimiter at 20/min sits in front of the paid Gemini calls.
The write queue
When a submit has nowhere to go, the payload and its media are copied somewhere durable and the item replays itself later.
It's a two-tier store, and the split is load-bearing. AsyncStorage holds only a ~300-byte index row per item; the payload and media spill to the app's document directory. A 100 km trip's route is one point per ~10 m — roughly 550 KB of JSON — and on Android AsyncStorage is a SQLite database with a hard cap that the whole read cache also lives inside. Writes past that cap fail silently. Keeping index rows tiny means the queue can never be the thing that overflows it.
The item's id is generated once at enqueue and doubles as its Idempotency-Key.
Server-side, middleware/idempotency.js claims the key atomically, stores the
response on success, and deletes the claim on a non-2xx so a genuine failure
stays retryable. A duplicate replays the original 2xx body, so a client that
retried after a timeout can't tell the difference between its first write and its
tenth — and no user sees their hazard posted twice.
Danger-zone alerts
The same threat detector runs in the foreground watcher and in the headless background task, which Android wakes in a fresh JS context after killing the app.
One 1 km threshold across all three sources, checked in urgency order: live GDACS disasters, then user hazard reports, then historical landslide zones. Each source is wrapped separately, so one outage can't mute the other two.
Key decisions & trade-offs
An SOS is never queued. Every other write survives offline; this one deliberately doesn't. A replayed alert fans a "someone needs help nearby" push out to everyone around the location the user was at, sending helpers to an empty roadside — and a channel that cries wolf gets muted, which is the one failure this app can't afford. Offline, the SOS button opens direct call and SMS options instead. That's a worse feature and a better decision.
Reads throw instead of returning []. The services layer originally caught
network failures and returned an empty array. TanStack Query caches that as a
success, overwriting good cached data — so the map rendered "no hazards nearby"
having never reached the server. Now a failed read throws OfflineError and the UI
branches on it. Related, and just as load-bearing: isPaused is tested above
isError, because a paused query has both isLoading and isError false and
otherwise falls straight through to the empty state.
Notifications go to every user, not nearby ones. Both fan-outs started as a
$near query on the user's last known location. That silently reached nobody: a
user who never granted location permission has no stored location and can therefore
never match a geo query — with no signal anywhere that they'd been skipped. Distance
is also the wrong filter for both cases; "come help me" can be answered by a relative
200 km away, and a blocked road matters to whoever might drive onto it. Physical
proximity is now handled device-side by the alert pipeline instead, with a cap on
any single fan-out. "It was just reported" and "you are approaching it" turned out
to be different questions that needed different mechanisms.
Critical writes never block on best-effort side effects. createSos persists the
SOS, then fires the notification fan-out un-awaited — and the push layer is documented
never to throw. A push-provider outage degrades delivery; it can never fail the write.
Startup takes the opposite stance and fails closed: no Mongo URI, or no CORS
allow-list in production, and the process exits at boot rather than serving in a
subtly wrong state. Cloudinary is the one permitted exception — absent config just
disables uploads.
MapLibre + OpenFreeMap over Google Maps. No billing-enabled key, no signup, and offline map packs come free with the SDK. The cost was paid in detail work: a map pin is a GL circle layer, not a native view, because a real React Native view on the map projection flickers and vanishes mid-zoom on Android — worst of all inside a modal. The consequence is that declaration order becomes paint order, so a pin that must sit above the hazard heatmap has to be declared after it.
A cache buster, treated as a release blocker. TanStack Query doesn't validate
hydrated data, so a persisted payload restored into code that expects a new shape is
a white screen on launch that survives relaunches until app data is cleared. Every
change to a query's return shape bumps CACHE_BUSTER in the same release. The stated
cost — every install loses its offline cache once — is far cheaper than the alternative.
The crash screen depends on nothing. No provider, no i18n, hardcoded bilingual copy, palette inline. It renders exactly when the app is already broken, and needing the thing that just broke is the one unforgivable bug in an error screen. Error boundaries sit at three depths so a broken screen keeps the tab bar alive, and the global handler writes the crash with a synchronous file write — an async one loses the race with process death.
Outcome
The app runs end to end: report a hazard with photos, video and a voice note; see it on a MapLibre map beside live GDACS alerts and landslide history; broadcast and resolve an SOS; record a trip in the background with the screen locked; get warned within 1 km of a known hazard with the app closed; and do all of the reads and most of the writes with no connection at all. English and Urdu are at full key parity.
What I'd carry forward isn't the feature list — it's the habit of asking what does this look like when it fails, and is that failure acceptable? Every decision above came from that question, and the two answers I'm most sure of are the ones where the honest behaviour was to do less: don't queue an SOS, and don't say "no hazards nearby" when what you mean is "I couldn't ask."