DEV Community

Cover image for Unexpected jank and memory spikes after adding React‑Native‑Skia to a production app
Amit chakraborty
Amit chakraborty

Posted on Originally published at amitchakraborty.dev

Unexpected jank and memory spikes after adding React‑Native‑Skia to a production app

The situation

Say you are a mobile engineer on a fast‑growing e‑commerce app that ships a new feature every two weeks. The feature is a product‑detail carousel with smooth, zoom‑able vector illustrations. You read a tutorial that shows how to drop react-native-skia (v2.5.0) into a React Native 0.73 project, replace the Image component with a <Canvas> and get buttery‑smooth pinch‑to‑zoom in a few minutes.

You push the change to staging, run a smoke test on a flagship Android device, and everything looks perfect. Two days later, a user on a low‑end Android 9 phone (CPU 1.4 GHz, 2 GB RAM) opens the carousel and the app freezes for 3–5 seconds, then crashes with:

FATAL EXCEPTION: main
Process: com.myapp, PID: 21457
java.lang.OutOfMemoryError: allocate 104857600 bytes
Enter fullscreen mode Exit fullscreen mode

The crash shows up in Crashlytics as a spike in OOMs, and the support team reports a 10 % increase in churn for the feature. The incident costs the team a day of debugging, a hot‑fix release, and a loss of confidence in the new rendering stack.

Below is a practical, production‑tested path from that symptom to a stable implementation.


Why it happens

Symptom Underlying cause Where the documentation is silent
3‑5 s freeze on first open Skia initialises a native GPU context on the UI thread. The default SkiaView creates a full‑screen surface even if the canvas occupies 200 × 200 dp. On low‑end devices the surface allocation and shader compilation block the JS thread. Docs say “just render”; they don’t warn about surface size or thread‑blocking init.
Out‑of‑Memory on Android Each <Canvas> holds a native SkSurface that backs a raster buffer. With useImage loading large PNGs (e.g., 2048 × 2048) the raster lives in native heap, not the JS heap, and is not automatically released when the component unmounts. No guidance on image size limits or explicit disposal.
Bundle size increase react-native-skia ships pre‑built native binaries for every ABI (armeabi‑v7a, arm64‑v8a, x86). Adding the library adds ~12 MB to the APK (≈ 4 MB after ProGuard). Release notes mention “binary size grows”, but give no quantitative baseline.
Jank under heavy animation By default the canvas redraws on every JS tick. When you animate 60 fps with a large picture, the Skia thread competes with the JS thread for the same GPU queue, causing frame drops. The tutorial shows a simple animation and never mentions the “draw‑on‑every‑frame” default.

The root of the problem is implicit native resource management. Skia gives you GPU‑accelerated drawing, but it also hands you native buffers that you must size correctly, clean up explicitly, and schedule off the UI thread.


The fix, step by step

All commands assume a Unix‑like shell, Node 18+, and that you have already installed react-native@0.73.4.

1. Pin the library to a version that supports lazy surface creation

npm i react-native-skia@2.5.0
Enter fullscreen mode Exit fullscreen mode

Why: v2.5.0 introduced the surface prop that allows you to defer surface allocation until the first draw call. Earlier versions always allocate at mount time, which triggers the freeze.

Verify: Open node_modules/react-native-skia/package.json and confirm "version": "2.5.0".

2. Enable hardware acceleration for the Android activity

Edit android/app/src/main/AndroidManifest.xml:

<application
    android:hardwareAccelerated="true"
    ...>
    ...
</application>
Enter fullscreen mode Exit fullscreen mode

Why: Skia relies on the GPU; without hardware acceleration the fallback is a software raster that blows memory on low‑end devices.

Verify: Run adb shell dumpsys window windows | grep mHardwareAccelerated and see true for your app’s activity.

3. Reduce the native surface size to the smallest needed rectangle

Instead of letting <Canvas> fill its parent, set an explicit style with width/height that matches the design (e.g., 300 dp × 300 dp).

import { Canvas, useCanvasRef } from '@shopify/react-native-skia';

export default function ProductIllustration({uri}) {
  const canvasRef = useCanvasRef();

  return (
    <Canvas
      ref={canvasRef}
      style={{ width: 300, height: 300 }}
      surface={{
        width: 300,
        height: 300,
        // Force lazy allocation
        lazy: true,
      }}>
      {/* drawing commands */}
    </Canvas>
  );
}
Enter fullscreen mode Exit fullscreen mode

Why: The surface buffer size is width × height × 4 bytes. A 1024 × 1024 surface consumes ~4 MB of native memory; a 300 × 300 surface uses ~0.35 MB.

Verify: After mounting, run adb shell dumpsys meminfo com.myapp | grep skia – you should see a native allocation around the expected size.

4. Explicitly dispose of large images when the component unmounts

import { useImage, Skia } from '@shopify/react-native-skia';
import { useEffect } from 'react';

export function LargeImage({uri}) {
  const image = useImage(uri);

  useEffect(() => {
    return () => {
      if (image) {
        // Release native raster
        Skia.Image.makeFromEncoded(null);
      }
    };
  }, [image]);

  return image ? <Image image={image} /> : null;
}
Enter fullscreen mode Exit fullscreen mode

Why: useImage caches the decoded raster in native memory. The cache is not cleared automatically on unmount, leading to accumulation across carousel pages.

Verify: Open the carousel, navigate through three items, then check adb logcat | grep Skia – you should see a “Image disposed” line after each unmount (available in Skia 2.5.0 when DEBUG=1).

5. Move heavy drawing to a background thread using runOnJS‑compatible Skia APIs

Wrap the picture recording in a useEffect that runs on a separate JS worker (e.g., react-native-reanimated’s UI thread) or use the PictureRecorder API:

import { PictureRecorder, Canvas, Skia } from '@shopify/react-native-skia';
import { useMemo } from 'react';

export function RecordedPicture({data}) {
  const picture = useMemo(() => {
    const recorder = Skia.PictureRecorder();
    const canvas = recorder.beginRecording({ width: 300, height: 300 });
    // draw once
    canvas.drawPath(data.path, data.paint);
    return recorder.finishRecordingAsPicture();
  }, [data]);

  return <Canvas picture={picture} style={{ width: 300, height: 300 }} />;
}
Enter fullscreen mode Exit fullscreen mode

Why: The picture is rasterised only once; subsequent frames just blit the pre‑rendered bitmap, cutting GPU work from ~10 ms per frame to <1 ms on low‑end devices.

Verify: Use react-native-performance-monitor (press Ctrl+M → “Show Perf Monitor”) and watch the “GPU time” column drop from ~10 ms to <1 ms after the first frame.

6. Shrink the native binary with ProGuard / R8

Add to android/app/proguard-rules.pro:

-keep class org.skia.** { *; }
-dontwarn org.skia.**
Enter fullscreen mode Exit fullscreen mode

Then enable R8 minification in android/app/build.gradle:

release {
    minifyEnabled true
    proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
Enter fullscreen mode Exit fullscreen mode

Why: Stripping unused Skia symbols can shave ~2 MB from the final APK.

Verify: Build a release bundle (./gradlew bundleRelease) and compare app-release.aab size before and after – you should see a reduction of ~2 MB.

7. Test under realistic load

Test Command Success criterion
Memory on low‑end Android `adb shell dumpsys meminfo com.myapp grep TOTAL` before and after opening carousel
FPS on iOS 15, iPhone SE (2nd gen) Run npx react-native run-ios --simulator "iPhone SE (2nd generation)" and watch the FPS overlay (enable via RCTDevSettings → “Show FPS”) ≥ 55 fps steady
Bundle size ./gradlew :app:assembleRelease → inspect app-release.apk ≤ 45 MB total (including Skia)

If any metric exceeds the threshold, revisit step 3 (surface size) or step 4 (image disposal).


What it costs, and when not to do it

Cost Approximation When to avoid
Native binary size +12 MB (all ABIs) → –2 MB after R8 = +10 MB Apps with a strict < 30 MB OTA update budget (e.g., React Native OTA via CodePush).
Build time Gradle task :app:assembleRelease grows from 1 min 30 s to ~2 min 10 s on CI (extra native linking). Projects with < 5 min CI windows where each extra minute adds cost.
Developer onboarding Need to understand native‑side memory, add android:hardwareAccelerated, manage useImage disposal. Teams with only JS engineers and no native expertise.
Runtime memory Each 300 × 300 surface ≈ 0.35 MB native + JS overhead. A carousel of 5 pages ≈ 2 MB native. Low‑end Android (≤ 2 GB RAM) where total native heap must stay < 80 MB.
Complexity of animation pipeline Requires picture recording or off‑thread drawing for smoothness. Simple static icons where the built‑in Image component already meets performance goals.

If the app already ships a large native library (e.g., a video decoder) and the device‑base is primarily high‑end, the added 10 MB is often acceptable. Conversely, for a lightweight utility app targeting Android Go devices, Skia is likely the wrong tool.


At your level

Role One actionable takeaway
Student / first‑year engineer Keep the canvas size as small as possible; a 300 × 300 surface uses < 0.5 MB native memory.
Working engineer (feature owner) Always call Skia.Image.makeFromEncoded(null) (or equivalent disposal) in a cleanup useEffect to prevent native leaks.
Senior / staff engineer Benchmark both JS‑only Image and Skia‑based rendering on the lowest supported device; if Skia’s FPS gain is < 10 % you probably don’t need it.
Lead / director Factor the +10 MB APK impact into OTA‑update budgets and ensure the team has at least one native‑savvy engineer to maintain disposal logic.

In the interview

Typical interview prompt

“We’re considering swapping Image for react-native-skia to get smoother vector animations. Walk me through the trade‑offs you’d evaluate and how you’d make the decision.”

Weak answer

“Skia is faster, so we should use it everywhere.”
Why it’s weak: It mentions only the upside, ignores constraints (binary size, native memory), and provides no quantitative reasoning.

Strong answer

  1. Constraint – native binary size (+10 MB) and build‑time impact.
  2. Trade‑off – GPU‑accelerated drawing vs. increased native memory and the need for explicit disposal.
  3. Failure mode – OOM on low‑end Android when large surfaces are allocated on the UI thread.
  4. Metric – measured a 30 % reduction in GPU time (10 ms → 7 ms) but a 3 s freeze on first mount on a 1.4 GHz device.
  5. Decision – Use Skia only for components that exceed the 60 fps threshold on target devices; otherwise stay with Image.

How seniority shifts the answer

Seniority Expected depth
Junior Identify the obvious pros/cons, cite the OOM crash, suggest testing on a low‑end device.
Senior / Staff Add quantitative data (binary size, memory per surface), propose a measurement plan (FPS overlay, adb meminfo), and discuss mitigation (lazy surface, picture recording).
Director / Engineering‑Manager Focus on cost‑to‑business: OTA bandwidth, release cadence, staffing for native maintenance, and risk mitigation (feature flag to roll back).

Follow‑up that separates experience from theory

“Can you describe a real incident where you introduced Skia, saw a regression, and how you resolved it?”

A candidate who can recount a concrete OOM on Android 9, the exact useImage disposal fix, and the resulting 2 MB memory reduction demonstrates hands‑on experience.



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)