DEV Community

Cover image for Defending the 16ms Frame: A Performance Budget for React Native Skia
Amit chakraborty
Amit chakraborty

Posted on Originally published at amitchakraborty.dev

Defending the 16ms Frame: A Performance Budget for React Native Skia

I was the first engineering hire at Synapsis Medical Technologies, where I eventually scaled the team to 21 engineers. We were building a HealthTech AI platform that required real-time visualisations of wearable data—ECG waveforms and live heart-rate variability—layered over a React Native UI.

We chose @shopify/react-native-skia because the standard React Native View hierarchy collapsed under the weight of 500+ data points updating at 60Hz. However, three weeks before a major clinical pilot, our "smooth" animations started stuttering. On an iPhone 15 Pro, it looked fine. On the mid-range Android devices our partner clinic used, the frame rate dropped to 22 FPS. The UI thread was clear, but the JavaScript thread was choking, and the GPU was waiting for data that arrived too late.

The cost was immediate: we couldn't pass the clinical usability audit. Stuttering waveforms in a medical context aren't just a "bad UX"—they look like equipment failure. We had to move from a vague "make it faster" mandate to a rigid performance budget that every engineer on the team had to defend.

Why Skia stutters in React Native

In React Native 0.76 and earlier, the bottleneck is rarely Skia’s C++ engine. It is the bridge or the JSI (JavaScript Interface) overhead.

When you use useDrawSelection or Canvas, every frame requires a trip from the JS thread to the UI thread. If you are calculating paths inside a standard useMemo and passing them as props to a Skia component, you are serialising data across the bridge. Even with the New Architecture (TurboModules), if your JS thread is busy with a heavy JSON parse or a RAG pipeline update—as was common in our AI workflows—the rendering instruction arrives late.

The "16ms budget" (for 60fps) or "8ms budget" (for 120fps) isn't just for drawing. It includes:

  1. JS Execution: Calculating the new coordinates.
  2. JSI Transfer: Moving that data to the Skia host objects.
  3. Rasterization: Skia actually drawing pixels.

If JS takes 12ms, you have only 4ms left for everything else. On a low-end Android device, Skia might need 10ms to rasterize a complex blur or a heavy path, putting you at 22ms total—well over the 16ms limit.

The Fix: Implementing the 16ms Ceiling

We moved from "best effort" rendering to a "budget-first" architecture. Follow these steps to lock down your rendering performance.

1. Externalise State to Skia Values

Stop using useState or useSharedValue from Reanimated for high-frequency Skia updates. Use Skia.makeMutable or useValue from the Skia library itself. This keeps the data within the Skia engine's reach without triggering React render cycles.

How to confirm: Open the React DevTools profiler. If your Skia component shows a re-render count matching your frame rate, you have failed this step. The component should render once, and the Skia Value should update the drawing internally.

2. Offload Path Generation to a Worklet

Generating a complex SVG path string in JS is expensive. For our ECG waveforms, we shifted path generation to useComputedValue.

// Do not do this in the main body of the component
const path = Skia.Path.Make();
points.forEach((p, i) => {
  if (i === 0) path.moveTo(p.x, p.y);
  else path.lineTo(p.x, p.y);
});

// Do this instead
const path = useComputedValue(() => {
  const p = Skia.Path.Make();
  // ... logic here
  return p;
}, [pointsValue]);
Enter fullscreen mode Exit fullscreen mode

Why: This ensures the path is built as a host object. You are passing a reference across the JSI, not a massive string or array of coordinates.

3. Establish the "Frame-Drop" Metric

We integrated react-native-performance to track js_fps and ui_fps. I set a CI gate: if the 95th percentile of frame time exceeded 16ms on a Galaxy A54 (our baseline low-end device), the PR was blocked.

The Command:
Use the Flashlight CLI (a tool for mobile performance measurement) to get an actual score:
flashlight measure --bundleId com.synapsis.app --duration 10000

Look for the "Frame Duration" metric. If the mean is >16ms, you are dropping frames.

4. Use Picture Recording for Static Overlays

If your UI has complex gradients or shadows that don't change every frame (like a background grid), do not redraw them. Use SkPicture.

const picture = useMemo(() => {
  const recorder = Skia.PictureRecorder();
  const canvas = recorder.beginRecording(rect);
  // Draw complex, static background here
  return recorder.finishRecording();
}, []);

// In your draw function
canvas.drawPicture(picture);
Enter fullscreen mode Exit fullscreen mode

How to confirm it worked: Use the Skia Overlay (<SkiaDomView debug />). You will see the "Draw Calls" count drop significantly. In our case, it dropped from 140 calls to 12 per frame.

The Cost of a Performance Budget

Enforcing a 16ms budget has trade-offs that aren't always pleasant:

  1. Development Velocity: It took my team roughly 30% longer to ship new visual features because they couldn't just "drop a component in." They had to architect the data flow to avoid the bridge.
  2. Code Complexity: You end up with "Skia-specific" state management that sits parallel to your Redux or Zustand store. Synchronising these two can lead to bugs where the UI shows one value while the Skia canvas shows another.
  3. Hardware Ceiling: There is a point where a device simply cannot render what you want. We had to implement "Feature Degradation"—detecting low-end GPUs and disabling Gaussian blurs or reducing the sample rate of the ECG waveform from 250Hz to 125Hz.

At your level

Starting out:
Stop using setInterval or requestAnimationFrame to drive animations. Use useTiming or useLoop from @shopify/react-native-skia. The one thing to do next: move one useState value that drives a Skia element into a useValue hook and observe the reduction in React re-renders.

Working engineer:
Profile your app using the Flashlight CLI on a physical Android device, not an iOS simulator. The simulator uses your Mac's GPU and will lie to you about performance. Your goal is to keep the "JS Thread" usage below 40% during animations to leave headroom for background tasks.

Senior or staff:
Architect a "Layered Rendering" strategy. Move static elements to the standard React Native View hierarchy (which is heavily optimised by the OS) and only use Skia for the volatile, high-frequency pixels. The one thing to do next: implement a PerformanceMonitor provider that samples frame times and automatically downgrades graphical fidelity (e.g., removing BlurMask) for users on older devices.

Lead or director:
Define the "Baseline Device." If you don't name a specific $200 Android phone as your target, your engineers will develop on $1,200 iPhones and ship a product that fails in the real world. Budget for a "Performance Sprint" every quarter where no features are added, only frame-time regressions are fixed.

In the interview

The Question: "How do you handle a performance bottleneck in a React Native app with complex graphics?"

Weak Answer: "I would use useMemo to cache calculations and make sure I'm not doing too much on the JS thread." (Too vague; doesn't acknowledge the specific constraints of the bridge or the GPU).

Strong Answer: A strong answer identifies the JSI bridge as the primary bottleneck. You should discuss the trade-off between Declarative API (easier to read, higher overhead) and the Imperative API (manual drawing, lower overhead). Mention specific tools like Flashlight or Perfetto for tracing.

Senior Follow-up: "How do you handle the synchronisation between a Skia animation and a React Native FlatList scroll?"
The Answer: This probes for knowledge of thread synchronisation. A senior candidate should explain that since Skia rendering happens on the UI thread (if using the right hooks), it can be perfectly synced with onScroll events using useSharedValue and useDerivedValue, provided you avoid the jump back to the JS thread. They should mention the risk of "checkerboarding" if the JS thread is too slow to feed the list while the GPU is busy drawing Skia layers.


Amit Chakraborty is a founding engineer and senior architect — React Native, AI/RAG systems and production architecture. Portfolio: www.amitchakraborty.dev · LinkedIn · GitHub. Open to senior and founding engineering roles, remote worldwide.

Top comments (0)