My phone app scrolled perfectly, but every button on the main screen ignored me for seconds. The app is the iPhone half of my Command Center. I built it in React Native, and it shares one JavaScript codebase with the Mac app it syncs with.

That hang made me look hard at the stack choice behind it. My answer, for this app: React Native was right, as long as you budget for the two places it stops helping you, heavy JavaScript on an engine with no JIT, and the bridge into native code.

Why React Native, for this app

The phone shows a graph of my data, and it lays that graph out with the same force simulation the desktop app uses. Same function, same inputs. A test holds the phone's graph coordinates identical to the desktop's, to the bit.

That test is the reason I would make the choice again. Two apps that must agree are hard to keep honest when they are written twice. Written once, with a check that fails the moment they drift, they stay honest without me watching.

What it cost: an engine with no JIT

The main screen drew a graph of 532 nodes. The simulation is O(N²) per tick, and it ran all 360 ticks in one go inside a single useMemo: about 51 million pair steps, on every launch and every time the Mac pushed new data. Then it mounted about 2,000 SVG elements over the bridge.

Scrolling is native and runs on the UI thread without asking JavaScript. A tap handler is JavaScript, so it waits for the JS thread. Smooth scroll with hanging taps means one thing: the JS thread is busy. And Hermes, the engine the app runs on, has no JIT. Node has one.

0 ms → 8,300 ms

the same layout in Node on my Mac, then with `node --jitless`

same code, same Mac, same data; plain Node hid the phone's cost by about 40 times

The fix had four parts. Cache by content hash, so unchanged data draws with no simulation. A stepped simulation on typed arrays that did 5.5 s of work in ticks of roughly 15 ms instead of 8.3 s in one block. About 16 drawn elements instead of about 2,000. And an automatic load that asks for the data's hash first. The whole hunt is in Hermes Has No JIT.

Where it had to go native

The Apple Health import crashed six times. I had a theory for every crash, and every theory was wrong. It was never memory. There was no JavaScript error, no red box, nothing in the log. The app was simply gone.

My metric registry said bpm for heart rate, a label for humans. HealthKit spells it count/min. The native side of the bridge passed my string to HKUnit(from:), an invalid unit raised an Objective-C exception on a HealthKit queue, and JavaScript cannot catch that. The process took a SIGABRT. Workout heart rate hardcoded count/min and never crashed: the control case, in the code the whole time. The full story is in Six Crashes Importing Apple Health.

Now labels are translated to HealthKit units at the boundary, against an allowlist. A unit not on the list refuses that one read, with a warning. An unreadable metric is a gap in the archive; a crashed app is a gap in everything. The lesson I keep: the bridge is where React Native stops protecting you, so read the crash log, then read the native source one call below yours.

Zoom went native too. The graph moved into its own full-screen explorer, zoomed with the native iOS ScrollView instead of a gesture library. A zoomed view is drawn at 1x and scaled as a bitmap, so canvas size is the memory bill, at three times the points in pixels. I keep the canvas under about 1,100 points.

How it ships: agents propose, checks decide

Every change to this app
  1. 1A spec
  2. 2A plan in the repo
  3. 3An agent, small diffs
  4. 4Tests on every step
  5. 5An AI review
  6. 6I merge

The agent runs the tests itself and fixes what it broke before it shows me anything. I read test changes first, because an agent asked to make tests pass may weaken the test instead of fixing the code. My rule is that agents propose, and checks have to pass before anything counts as done. The loop is written up in The AI SDLC I Use to Ship Mobile Features.

The model writes most of the code. I still own every line that ships.

The line I'd give a team for 2027

Whether to build in React Native or fully native is a live question for teams planning 2027. This build gave me one layout I can prove matches the desktop with a test. It cost me one hung screen and an import that crashed six times. So this is the sentence I would hand a team:

Choose React Native when one shared codebase is the point, then measure your JavaScript on an engine with no JIT and treat every bridge call as native code you have to read.

If you want the working loop behind that build, the free playbook below is where I keep it.

Free resourceThe AI SDLC PlaybookThe loop I ship mobile features with: spec, plan in the repo, small agent diffs, tests, AI review, a human merge.Get the playbook