The problem
The product was a heart-rate tracking app for a HealthTech company. Medical-grade UX has a non-negotiable rule: the first sensor reading must appear within a tight TTI budget after launch — otherwise users literally distrust the device and uninstall.
On the legacy build, cold-start from a fully-killed state averaged 1.8s on mid-range Androidand ~1.4s on iOS. Drop-off before the first reading screen rendered was measurable. Worse, the team had accumulated 100+ deprecation warnings across two RN minor versions without migrating, and Hermes wasn't enabled on the Android build.
What I looked at first
Before touching any code, I traced one cold launch end-to-end with Systrace on Android and Instruments on iOS. The breakdown was eye-opening:
- Native init: 320ms
- JS bundle parse (JSC, no Hermes): 540ms ← biggest single block
- Bridge init + module registration: 280ms
- JS execute → root render: 460ms
- Heart-rate service init: 200ms (blocking the render!)
Two clear targets jumped out: enable Hermes (kills the parse cost), and defer the heart-rate service init off the critical path.
The migration
We were two RN minors behind. Skipping a version just to cherry-pick Hermes wasn't safe — the dependency tree had broken peer ranges. I planned the migration as three sequential, testable steps:
- Bump RN minor 1 with all Android/iOS native code regen, fix deprecations in
RCTBridgeusage and olduseNativeDriverpatterns. - Bump RN minor 2 alongside enabling Hermes on Android. This forced fixes in 4 third-party libs that were calling JSC-specific globals.
- Defer non-critical native modules — analytics, crash reporting, deep-link handler — to register after the first paint via a
InteractionManager.runAfterInteractionswrapper.
console.log-based perf assertions because it formats numbers slightly differently in stringified objects. Tests had to be rewritten with a real comparison harness — which was a good thing in retrospect.Redux → MobX, and what it actually saved
Mid-migration we discovered the heart-rate stream was driving a Redux store with ~30 actions per second during a measurement. Each action cycled through middleware, sagas, and a deep combineReducers tree. The store wasn't the bottleneck for cold-start specifically, but it was a death-by-a-thousand-cuts problem for measurement screens.
I moved the streaming domain to MobX observables. The heart-rate service writes directly to observable atoms; React components read via observer() and re-render granularly without going through any global dispatch.
The cleanup was real: −40% linesacross the affected modules, mostly action creators, action types, reducer cases, and saga plumbing that didn't need to exist.
The win wasn't MobX-vs-Redux. It was that streaming domain logic belongs near the source of truth, not in a global pipeline.
Hook library extraction
While moving screens to MobX, I noticed the same five primitives kept showing up: useDebounced, useInterval with background-pause, useFocusedTimer, useFormValidation, and a typed useMeasurement for the heart-rate service.
I extracted them into an internal @app/hooks package with full type-safety and unit tests. By the end of the project, that package was used across 10+ screens, and per-feature dev time on new sensor screens dropped noticeably.
The result
- Cold-start TTI: 1.8s → 1.35s on the 50th-percentile Android device (−25%)
- State boilerplate: −40% across the streaming domain, measured by lines-of-code-removed minus added
- Hook library reused on 10+ screens, became the template pattern for the next two sensor integrations
- 0 deprecation warnings after migration, with a CI guard preventing regressions
What I'd do differently
I'd run the Hermes flag-flip in production behind a remote config for the first 48 hours. We rolled it out global-on-merge and caught one crash in a third-party lib that only manifested on Android 8 — a rollback would've been faster than a hotfix.
I'd also have written the perf assertions before the migration, not during. The temptation to start coding immediately is always strongest when the metric is obvious.