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 c

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. 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: JS Execution: Calculating the new coordinates. JSI Transfer: Moving that data to the Skia host objects. 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. We moved from "best effort" rendering to a "budget-first" architecture. Follow these steps to lock down your rendering performance. 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. 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]); 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. 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: flashlight measure --bundleId com.synapsis.app --duration 10000 Look for the "Frame Duration" metric. If the mean is >16ms, you are dropping frames. 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); 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. Enforcing a 16ms budget has trade-offs that aren't always pleasant: 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. 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. 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. Starting out: 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: Senior or staff: PerformanceMonitor provider that samples frame times and automatically downgrades graphical fidelity (e.g., removing BlurMask) for users on older devices. Lead or director: 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.
Key Takeaways
- •I was the first engineering hire at Synapsis Medical Technologies, where I eventually scaled the team to 21 engineers
- •This story was reported by Dev.to, covering developments in the dev space.
- •AI advancements continue to reshape industries — read the full article on Dev.to for complete coverage.
📖 Continue reading the full article:
Read Full Article on Dev.to →


