My phone app scrolled perfectly. Every button on the main screen ignored me for seconds.
If you build React Native apps, you've probably met this one. The symptom tells you almost everything, once you know how to read it.
Scroll works, taps don't
Scrolling is native. The list moves on the UI thread without asking JavaScript for anything. A tap is different: its handler is JavaScript, so it waits in line for the JS thread.
So when scrolling is smooth and taps hang, the JS thread is busy. Not the network, not the GPU. Something is running, and nothing else can run until it's done.
- 1You tap
- 2The handler queues for the JS thread
- 3A long computation holds the thread
- 4The tap runs seconds later
What was holding the thread
The main screen drew a graph of my notes: 532 nodes, laid out with the same force simulation the desktop app uses. It's O(N²) per tick, and it ran all 360 ticks in one go, inside a single useMemo.
That's about 51 million pair steps, on every launch and every time my Mac pushed new data to the phone. Then it mounted about 2,000 SVG elements over the bridge.
Why I didn't catch it: the JIT
I had timed that layout. In Node on my Mac it took 192 milliseconds. Not great, nothing alarming.
But Node has a JIT compiler, and Hermes, the engine my React Native app runs on, doesn't. A JIT turns hot loops into machine code while they run, so a tight O(N²) loop is exactly where it shines, and exactly where an engine without one falls furthest behind.
You can make Node behave like the phone: node --jitless switches the JIT off. Same code, same Mac, same data.
0 ms
for the same layout with the JIT switched off
192 ms with it on. The phone lives with the slow number.
A plain Node timing had hidden the phone's cost by about 40 times. The fast number was the one I trusted, and it was the one that didn't apply to a single user.
How I time phone JavaScript now
- 1Use the phone's real dataMy system can print the exact read-model the phone downloads, about 2 MB, offline and without writing anything. Benchmark against that, not against a fixture with ten rows.
- 2Switch the JIT offTime the screen's pure functions under
node --jitless. That's the number that matched the phone, not the one your laptop shows by default. - 3Budget the tap path in millisecondsAnything that can run between a tap and its answer gets about a frame's worth of time per slice: my layout now runs in slices of 12-15 ms. If the work can't fit, it's split or moved off that path.
- 4Confirm on a release buildMy app has no over-the-air updates, so a fix only counts once it's rebuilt and installed on the phone. Check it there.
What I moved off the tap path
Before
Before the fix
- 8.3 s of layout in one block, on every launch
- About 2,000 SVG elements
- The full 2 MB download and parse on every automatic load
- Every mounted screen re-rendered on every app render
After
After the fix
- No layout at all while the data is unchanged
- New data laid out in small slices after the screen settles
- About 16 elements
- The download skipped when nothing changed
- Cache by content hash. An unchanged dataset draws from a cache keyed by its hash. No simulation at all.
- Step, don't block. The same simulation became a stepper on typed arrays: 5.5 s of work in ticks of roughly 15 ms instead of 8.3 s in one block. New data lays out after the screen settles, 12 ms at a time, with the last graph still on screen. A test holds the coordinates identical to the desktop's, to the bit.
- Draw less. One path for all the links and one per colour: about 16 elements instead of about 2,000. A tap picks the nearest dot within 18 px, because the dots are 2-5 px and a finger isn't.
- Stop re-rendering the world. A context provider built a new value on every render, so every auth event re-rendered every mounted screen. Memoised, together with the value it hands down.
- Don't download what you have. An automatic load asks for the data's hash and timestamp first, and skips the 2 MB download, parse and re-render when both match. A manual refresh still downloads it whole.
- Watch your ref callbacks. A heatmap scrolled itself to the end on every render, through an inline ref callback. Now it happens once per content size.
One more: zoom costs memory
The graph then moved off the main screen into its own full-screen explorer, zoomed with the native iOS ScrollView instead of a gesture library. A zoomed view is drawn at 1× and scaled up as a bitmap, so the canvas size is your memory bill, at three times the points in pixels.
I keep that canvas under about 1,100 points. On a phone, every point you draw is memory you pay for.
Your Mac has a JIT. Your users' phones don't. Benchmark like the phone.
Building with AI?Let's talk about your appBook a free intro call


