From PWA to Native: Designing for Complexity
The client’s product began as a web-first wellness platform—shared backend, and offline-friendly data fetching carried over from the PWA era (offlineFirst React Query, separate branding tokens for web and native surfaces). That worked while logging was mostly manual and hardware was optional.
As the program matured, device sync became core: Bluetooth body-composition scales, camera-based food logging with AI, push notifications that route into chat and quests, and store distribution with version gates. A responsive PWA could not own those concerns reliably across iOS and Android. We migrated to Expo SDK 53 + React Native, EAS builds, and custom native modules—without throwing away the PWA’s cache-first data habits.
The Problem
The PWA hit platform ceilings
| Capability | PWA limitation |
|---|---|
| Vendor BLE scales | Web Bluetooth does not expose proprietary Omron, Senssun, or Qingniu SDKs; pairing and historical sync need native bridges. |
| Camera + AI food logging | Mobile Safari and installed PWAs constrain camera permissions, performance, and background behavior vs native camera APIs. |
| Push → deep navigation | FCM + channel control (Notifee) and cold-start routing into chat or quest screens are first-class on native, fragile on web. |
| App Store lifecycle | Mandatory update checks, OTA via Expo Updates, and per-tenant bundle IDs are store-app problems, not tab-in-browser problems. |
| Rich native UI | Skia charts, Three.js body models via native GL, and Lottie at 60fps are poor fits for a typical mobile web shell. |
Complexity did not disappear—it moved
Shared environment naming (legacy web public env vars beside native Expo public vars), dual tenant builds, and three separate scale SDK integrations each with config plugins, binary frameworks, and permission flows. The migration was not “rewrite JSX”—it was vendor-native boundaries plus preserving offline assumptions from the web app.
Requirements we had to meet
- Pair and sync smart scales (multiple brands) with reliable BLE on iOS and Android.
- Ship on App Store and Play Store with EAS channels, runtime versions, and tenant variants.
- Retain offline-capable quest logging inherited from the PWA data layer.
- Defer HealthKit / Health Connect until product-ready (scaffolded in config, not shipped).
The Solution
Why native wins over PWA for this product
1. Hardware is the product differentiator
Custom Expo modules wrap vendor SDKs:
- Omron — pairing, scan, connect, vital data transfer; iOS ships vendor xcframeworks.
- Senssun — BLE scales with 150+ body-composition fields; cross-platform module parity documented for iOS/Android interface alignment.
- Qingniu — Android BLE scale SDK (native bridge built; rollout phased across the app).
The Expo app config declares Bluetooth and location (Android BLE scan), injects API keys via config plugins, and documents usage strings for health peripherals. None of this is reproducible as an installable PWA with the same reliability.
2. Camera-native food quest
Food logging uses the native camera module, gallery fallback, and server-side AI analysis. Native capture paths feed offline reconcile (local file → upload on reconnect)—file handling and permissions are simpler and more predictable than mobile browser camera APIs.
3. Push as an orchestration layer
Firebase Cloud Messaging plus Notifee handles channels, badges, and press actions. Background handlers route users into in-app chat (Sendbird payloads) or scale-sync notification types. Web push cannot match Android channel semantics or iOS cold-start navigation via Expo Router.
4. Store + OTA operations
EAS Build/Submit, runtime versioning, and update channels let the client ship JS fixes without waiting for full store review where policy allows—while a store version checker enforces minimum native builds when APIs break compatibility.
5. Inherited PWA strengths, native shell
The React Query layer still prioritizes cached and offline data (networkMode: 'offlineFirst') with MMKV persistence—so migration cost focused on platform gaps, not rethinking every screen’s data pattern.
Key Outcomes
- Store-native distribution with EAS pipelines, OTA updates, and version enforcement.
- Bluetooth scale sync for multiple vendors via custom native modules—not a polyfill.
- Camera + AI food pipeline with native capture and offline reconcile path.
- Push-driven engagement with deep links into chat and notification.
- Preserved offline-first data habits from the PWA (React Query + MMKV) inside the native shell.
- Clear deferred scope — HealthKit/Health Connect ready in config, shipped when product demands it.
PWA vs Native — Decision Summary
| Dimension | PWA | Native app |
|---|---|---|
| Smart scale sync | Not viable with vendor SDKs | Custom BLE modules per brand |
| Food photo + AI | Constrained camera APIs | Native camera module + on-device file paths |
| Notifications | Limited parity | FCM + Notifee + background routing |
| Offline quest logs | Service worker / cache strategies | MMKV + paused mutations + reconcile |
| Distribution | URL / add-to-home | App Store + Play Store + EAS OTA |
| 3D / charts performance | Heavy WebGL | Skia + native GL |
| Attestation | Origin-based | Mobile app signature |
What Changed for the Organization
| PWA-first | Native companion |
|---|---|
| Browser tab, no BLE SDK access | Installed app with paired peripherals |
| Email or generic web push | FCM with quest/chat routing |
| Responsive layout focus | Permissions, background modes, store compliance |
| Single web deploy | Multi-tenant EAS builds + native binary deps |
| Manual weight entry | Scale sync notifications and automated body metrics |
Lessons & Next Steps
What worked: Treating native as the hardware and notification layer while keeping the data layer familiar (React Query offline-first) reduced product risk. Splitting scale vendors into isolated modules contained SDK conflicts.
Core lesson: The hard part was not React vs React Native. It was deciding which problems only native can solve (BLE, push, store, camera files) while not rewriting what the PWA already solved well (cache-first reads and offline-capable quest writes).
Stack: Expo · React Native · EAS · Custom native modules (BLE) · FCM · Notifee · TanStack Query · MMKV