React Native performance
Measuring perceived latency in React Native when frame metrics are not enough
Frame stats can tell you the renderer is under pressure. They do not always tell you why a player taps a cell and waits for the board to move.
The metric that looks most scientific is not always the one that explains the bug.
We hit this while testing a Skia renderer in Daily Sudoku: Offline Puzzle. Android frame stats, jank counts, and rendering
percentiles were useful background signals, but they did not explain the thing we could feel by hand: on some devices, the
screen reacted late after a tap.
So we stopped starting with frame charts and changed the question.
Instead of asking “how many frames were janky?”, we started asking:
For this exact user action, how long does the app take to move from handler start to state update to visible paint?
Why frame metrics were not enough
Frame metrics are valuable when rendering is already the suspect. They can show dropped frames, slow draw commands, or a UI thread under pressure.
But they are not always a good first benchmark for perceived latency.
A player does not feel a percentile. They feel the delay between a tap and the first visible response. If the screen sits still for half a second, a clean aggregate metric does not save the interaction.
For our Sudoku board, the confusing part was that devices did not line up with intuition. An emulator could look fast while a real low-end phone looked slow. A high-end phone looked better, but still not instant during theme changes.
That told us the benchmark needed to follow the action, not the frame pipeline.
The internal probe
We added a small observability probe behind config:
observability:
latencyProbe:
enabled: true
prefix: "[SUDOKU-OBSERVER-LATENCY]"
The app logs three stages for selected interactions:
start: the action handler started.state: the app or React state finished updating.paint: the secondrequestAnimationFrameafter the state update.
The split matters:
start -> statepoints to action logic, reducers, persistence, or game mutation cost.state -> paintpoints to rendering, reconciliation, canvas work, theme propagation, or UI thread pressure.start -> paintis the internal response latency that best matched what we wanted to compare.
This is not the same as a high-speed camera measuring finger-to-pixel latency. It does not include hardware touch sampling, OS input dispatch, display refresh timing, or video evidence. But it is repeatable, cheap to run on real devices, and close enough to show where the app itself is spending time.
What we measured
We instrumented actions players repeat enough to judge:
- Selecting a Sudoku cell.
- Pressing a number or note.
- Changing the app theme.
Then we collected logcat output from each device:
adb logcat | rg "SUDOKU-OBSERVER-LATENCY"
The rule was consistency: same app build, same config flag, same screen, same action set. If a device could not produce a complete probe for an action, we marked the sample as weak instead of pretending it belonged in the comparison.
The useful result
The first useful finding was not subtle: state updates were fast.
On the slow Redmi Note 7, cell selection state usually completed around 1.5ms, but paint landed around 467ms median and
621ms p95. Theme changes were worse: around 1063ms median to paint.
On the faster POCO X6 Pro, cell selection paint was around 215ms median and theme changes around 442ms median. Better,
but still not instant.
That distinction changed the next move. If state is fast and paint is late, optimizing game rules or input handlers is mostly busywork. The investigation belongs around render work, React reconciliation, Skia drawing, theme propagation, and device-specific UI pressure.
A later optimization pass
The same probe became more useful once we stopped treating “the board” as one thing.
The first Skia board was slower than the React Native baseline on a Redmi Note 7 because every selection still propagated
through the game screen and rebuilt too much visual state. A single-surface Skia renderer helped, but not enough by itself:
cell selection moved from roughly 384ms on the React Native renderer to 210ms on the first Skia spike.
Then the probe forced the architectural question we had been avoiding:
Which parts should react to selection, and which parts should stay static?
We moved selected-cell state out of GameScreen and into a local store consumed only by the board, controls, and number pad.
That removed GameScreen renders during the 20-selection protocol and brought the Note 7 median down to 163ms.
The next pass split the Skia surface into stable layers:
- Board cells, values, static notes, wrong borders, and grid lines stopped depending on selection.
- Selection highlights moved into their own layer.
- Note highlights were separated from static note text.
On the same Note 7 protocol, the median dropped to 149ms. It did not reach the “feels instant” target of <100ms, but it
crossed the technical continuation cutoff we had set for the spike. More importantly, it told us where not to keep digging:
game rules and screen state were no longer the main cause.
What this changed
The probe changed the debugging conversation from vague to actionable.
Before the probe:
- “It feels slow on this phone.”
- “The emulator looks fine.”
- “The frame stats are hard to interpret.”
After the probe:
- “Cell selection on the Note 7 paints in roughly half a second.”
- “Theme changes cross one second on the Note 7.”
- “State is not the bottleneck.”
- “Investigate
state -> paint, notstart -> state.”
That is a better debugging loop because it points at ownership, not vibes.
Lessons
Use frame metrics, Perfetto, and GPU tooling when they answer the next question. But do not start there if the product question is response latency.
For React Native apps, a small internal probe can be the fastest way to separate logic latency from visual latency:
- Measure named user actions, not generic app activity.
- Log stages that map to engineering ownership.
- Keep the probe behind config.
- Run the same action set on every target device.
- Treat incomplete probes as incomplete data.
The goal is not to replace deep profiling. The goal is to stop profiling the wrong thing first.
Choosing which actions deserve probes
Not every interaction needs instrumentation. The best candidates are actions users repeat often and judge instantly: selecting a cell, pressing a number, opening a modal, applying a theme, or triggering an animation that should feel immediate.
For those actions, measure around the product moment, not around the implementation detail. A cell selection probe should start when the handler receives the tap and end when the selected state is visibly painted. If the probe starts later, it can hide input delay. If it ends too early, it can hide rendering work.
How to read the numbers
The median tells you the normal feeling. P90 shows how often the app becomes noticeably late. The maximum helps find outliers, but it should not be the only decision maker. A single bad sample can come from background device work; a consistent P90 problem means the interaction is unreliable.
The most useful comparison is before and after on the same device with the same script. Cross-device numbers are useful for product risk, but they are not always useful for judging whether a code change helped. Keep the protocol stable: same board, same interaction order, same delays, same build type.
What to do after a probe finds latency
A probe does not fix anything. It narrows the search. Once a slow interaction is confirmed, inspect what re-rendered, what state changed, which animation started, and whether persistence or analytics is blocking the path.
That is where small architectural changes become easier to justify. Moving selection state out of a parent screen, isolating a renderer, or memoizing a dense component should come from measured pressure, not habit. The probe gives the team permission to make one boring targeted change instead of rewriting the screen.
Keep the probe cheap to remove
Instrumentation should not become the architecture. A latency probe is most useful when it can be enabled by config, kept out of production noise, and removed without changing feature behavior. If the probe owns state or control flow, it starts influencing the thing it is supposed to measure.
The best probes are small wrappers around existing moments: handler start, state committed, paint observed, animation completed. They should describe the app; they should not become the app.
Conclusion
Perceived latency work gets better when the measurement follows the user action. A small probe cannot replace judgment, but it can show which delay is real, repeatable, and worth fixing before a rewrite starts looking tempting.