The situation
You are a lead mobile engineer on a feature team that ships a complex data‑visualisation screen.
The screen currently uses a hierarchy of View/Animated.View components to draw dozens of animated charts. During a sprint review the product owner notes that on a low‑end Android phone the UI drops to 30 fps, causing visible jank and a user‑complaint ticket. The team estimates that fixing the problem will require a rewrite using the React Native Skia library, which promises GPU‑accelerated drawing.
The symptom you see in the CI test report is a 30 % increase in frame‑time variance on the target device, and the ticket has already delayed the next release by two days. You need to know whether the Skia rewrite will actually improve the numbers enough to justify the added native code, bundle‑size increase and maintenance overhead.
Why it happens
React Native renders most UI through the JavaScript bridge: each layout pass, style calculation and drawing command is marshalled from JS to the native UI thread. When a screen contains many overlapping animated elements, the bridge becomes a bottleneck, the UI thread spends a large fraction of each 16 ms frame on layout work, and the GPU receives many small draw calls.
Skia bypasses the bridge for the drawing of those elements: the component builds a Skia picture on the JSI thread and hands a pre‑rasterised texture to the compositor. In theory this reduces bridge traffic and consolidates draw calls, but it also introduces:
- a native dependency that must be compiled for each platform,
- an extra memory allocation for the Skia picture,
- a larger JavaScript bundle (≈ 2–3 MiB for the Skia module and its native binaries).
Because the performance gain depends on the actual cost of the bridge and the shape of the animation workload, you cannot assume a win without measurement. Moreover, instrumentation itself adds overhead; if you profile the dev build you may see optimistic numbers that disappear in a release build.
The fix, step by step
The goal is to obtain repeatable, production‑like metrics for the current implementation, apply the Skia rewrite in a narrow scope, and compare the two sets of numbers. Follow the steps exactly; each step includes a verification point.
1. Choose representative devices
| Device class | Example model | OS version | Reason |
|---|---|---|---|
| Low‑end Android | Samsung Galaxy A12 | Android 12 | Matches the complaint |
| Mid‑tier iOS | iPhone 12 | iOS 16 | Baseline for Apple devices |
Why: Performance varies dramatically across GPU and CPU capabilities. Measuring on at least one low‑end Android and one mid‑tier iOS device covers the worst‑case and a typical case.
How to confirm: Run adb shell getprop ro.product.model (Android) or check Settings → About → Model (iOS) and note the output.
2. Build a release binary with profiling enabled
# Android
cd android
./gradlew assembleRelease -PenableProfiling=true
# iOS
cd ios
xcodebuild -scheme MyApp -configuration Release \
-derivedDataPath build \
OTHER_CFLAGS="-DENABLE_PROFILING=1"
Why: Release builds disable the JavaScript dev overlay, enable Hermes optimisation (if used), and run the JS bundle through the optimiser. The -PenableProfiling flag (available from RN 0.71) adds the RCTProfile hooks without the dev‑tools UI.
Confirm: The generated APK (app-release.apk) size should be ~10 % larger than the debug APK; the iOS .app bundle should contain libreactnative.so with the profiling symbol. Verify by checking the file size difference.
3. Warm‑up the app
Launch the app on the device, navigate to the visualisation screen three times, letting each animation run to completion. Then close the app (force‑stop on Android, swipe‑up on iOS).
Why: JIT compilation, cache population and GPU driver warm‑up can skew the first‑run timings. A warm‑up run ensures the subsequent measurement reflects steady‑state performance.
Confirm: No crash logs appear in logcat (Android) or Console (iOS) after the third run.
4. Capture baseline frame metrics with Flipper
- Open Flipper → React Native → Performance Monitor.
- Press Start Recording, navigate to the screen, and let the animation run for 10 seconds.
- Stop recording and export the JSON (
baseline.json).
Metrics to note (found under frameMetrics):
- Average FPS (should be ≈ 30 fps on low‑end Android).
- JS thread time (ms per frame).
- UI thread time (ms per frame).
- GPU time (if reported).
Confirm: The JSON contains an entry "averageFPS": 30.2 (or similar). If the file is empty, ensure the device is connected and the react-native-flipper package is up‑to‑date (≥ 0.200.0).
5. Record a Systrace for deeper insight
# Android
adb shell "atrace --async_start -c -b 4096 -t 10 gfx view wm"
adb shell "input tap <x> <y>" # open the screen
sleep 10
adb shell "atrace --async_stop -c -b 4096 -t 10 gfx view wm" > baseline.trace
Why: Systrace shows the exact time spent in each subsystem per frame (Choreographer#doFrame, Layout, Draw). Look for long gaps (> 5 ms) in the UI thread.
Confirm: Open baseline.trace in Android Studio's CPU Profiler → Trace view. You should see a repeated pattern of Choreographer#doFrame lasting ~ 33 ms.
6. Enable Skia’s debug overlay (optional, for later comparison)
Add the following to your SkiaView component:
import { Skia } from '@shopify/react-native-skia';
<SkiaView
style={styles.canvas}
debug={true} // draws overlay with draw‑call count
/>
Why: The overlay prints the number of draw calls per frame on the canvas. This gives a quick visual cue of whether Skia is consolidating work.
Confirm: When the screen renders, a small HUD appears showing “Draw calls: 12”. If the overlay does not appear, ensure you are using react-native-skia ≥ 0.8.0.
7. Take a baseline memory snapshot
In Flipper → React Native → Memory click Take Snapshot while the animation is running. Record the JS heap size and native memory.
Confirm: The snapshot shows numbers like JS Heap: 45 MiB, Native: 120 MiB. Note them for later comparison.
8. Implement the Skia rewrite in a feature branch
Scope the change to a single chart component:
import { Canvas, Path, Paint } from '@shopify/react-native-skia';
export const SkiaChart = ({ data }) => {
const path = useMemo(() => {
const p = Path.Make();
data.forEach((pt, i) => {
i === 0 ? p.moveTo(pt.x, pt.y) : p.lineTo(pt.x, pt.y);
});
return p;
}, [data]);
const paint = Paint();
paint.setColor(Skia.Color('#3b82f6'));
paint.setStyle(Skia.PaintStyle.Stroke);
paint.setStrokeWidth(2);
return <Canvas style={styles.canvas}><Path path={path} paint={paint} /></Canvas>;
};
Why: Keeping the rewrite isolated lets you compare the same UI path with minimal confounding changes.
Confirm: The component renders correctly in the dev build (use npm start and react-native run-android).
9. Re‑run steps 4‑7 on the Skia branch
Repeat the Flipper performance capture, Systrace, debug overlay, and memory snapshot, naming the files skia.json, skia.trace, skia_memory.png.
Confirm: Each export completes without error. The overlay now shows a lower draw‑call count (e.g., “Draw calls: 4”).
10. Analyse the data
| Metric | Baseline | Skia | Δ (Improvement) |
|---|---|---|---|
| Avg FPS | 30 fps | 48 fps | +60 % |
| JS thread time | 7 ms | 5 ms | –29 % |
| UI thread time | 12 ms | 6 ms | –50 % |
| GPU time | 8 ms | 4 ms | –50 % |
| JS heap | 45 MiB | 46 MiB | ≈ 0 % |
| Native memory | 120 MiB | 124 MiB | +3 % |
| Draw calls (overlay) | 18 | 4 | –78 % |
Why: The table gives a concrete, side‑by‑side view. The FPS gain exceeds the 15 % threshold that typically justifies a rewrite on low‑end hardware. The memory impact is negligible (< 5 %). The UI thread time halved, confirming the bridge bottleneck was the primary issue.
If the numbers do not meet your target (e.g., FPS improves only to 34 fps), you may decide the added native complexity is not worthwhile.
11. Document the regression‑test script
Create a CI step that runs the same profiling commands on a headless emulator (Android API 33, device‑type pixel_2). Store the JSON output as an artifact and compare it against the baseline using a simple diff script:
node comparePerf.js baseline.json skia.json
comparePerf.js should exit with a non‑zero code if any metric falls below a configurable threshold (e.g., FPS < 45 on low‑end). This guards against future regressions.
Confirm: The CI pipeline fails when you deliberately introduce a performance bug (e.g., add an extra setState in the chart), proving the test catches regressions.
What it costs, and when not to do it
| Cost | Description |
|---|---|
| Developer time | Initial Skia integration (≈ 2 person‑days) + profiling scaffolding (≈ 1 day). |
| Bundle size |
react-native-skia adds ~ 2.8 MiB (Android .aar, iOS .framework). |
| Native maintenance | Requires keeping the native module in sync with RN upgrades; on RN 0.76+ you must also update the JSI bindings. |
| Testing matrix | Need to verify both Android and iOS native builds, plus any custom native code (e.g., Gradle externalNativeBuild). |
| Risk of crashes | Skia can expose GPU driver bugs on older Android kernels; you may see “GL_INVALID_OPERATION” logs under heavy load. |
When to skip the rewrite
- Baseline FPS is already ≥ 55 fps on the lowest target device.
- The visualisation is static or updates at ≤ 10 Hz; bridge traffic is minimal.
- The team lacks native‑C++ expertise and cannot allocate time for ongoing maintenance.
- The app’s bundle size is already near the OTA limit (e.g., 100 MiB for a managed Expo build).
In those cases, the marginal gain does not outweigh the added complexity.
At your level
| Role | Immediate takeaway | Next concrete step |
|---|---|---|
| Student / first‑year | Performance gains must be measured, not guessed. | Run the Flipper Performance Monitor on a simple demo screen and record the FPS. |
| Mid‑level engineer | A reproducible profiling pipeline lets you justify architectural changes to stakeholders. | Add the comparePerf.js script to your repository and make it a required check before merging UI changes. |
| Senior / staff | The trade‑off is GPU‑accelerated draw vs. native maintenance & bundle bloat. Quantify it with a per‑device FPS target and a size budget. | Propose a “Skia guardrail” policy: only adopt Skia when baseline UI thread time > 8 ms and projected FPS gain > 15 %. |
| Lead / director | The cost of a Skia rewrite is measured in developer‑days and OTA size; the benefit is a measurable reduction in churn‑related support tickets. | Allocate a sprint budget (e.g., 3 person‑weeks) for the profiling effort and track post‑release support tickets to validate ROI. |
In the interview
Typical interview prompt
“We have a screen that drops below 30 fps on low‑end Android devices. We are considering rewriting it with React Native Skia. Walk me through how you would decide whether the rewrite is worth it.”
Weak answer
“Skia is faster because it uses the GPU, so we should just replace the Views with Skia components.”
Why it’s weak: It states the expected outcome without any evidence‑gathering, ignores measurement, and does not address the cost or failure modes.
Strong answer (what you should hear)
- Define the metric and target – “We need ≥ 45 fps on the lowest supported device; anything below that is a regression.”
- Establish a production‑like baseline – “Run a release build on a low‑
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)