DEV Community

Cover image for Reanimated 4 worklets: production‑only breakage and early detection
Amit chakraborty
Amit chakraborty

Posted on Originally published at amitchakraborty.dev

Reanimated 4 worklets: production‑only breakage and early detection

The situation

Say you are a mobile engineer on a fast‑growing e‑commerce app that ships a new “swipe‑to‑add‑to‑cart” interaction every two weeks. You decide to implement the animation with react‑native‑reanimated 4 because the worklet model promises 60 fps on the UI thread.

After a weekend release you start receiving crash reports from a handful of Android 12 devices:

FATAL EXCEPTION: ReanimatedJSI
java.lang.IllegalStateException: Worklet execution failed: Cannot serialize object of type 'Map'
Enter fullscreen mode Exit fullscreen mode

At the same time, telemetry shows a 20 % increase in UI‑thread CPU usage and a noticeable lag on screens that render more than ten concurrent worklets.

The impact: a week of hot‑fixes, a delayed marketing campaign, and a spike in negative app‑store reviews.

The following guide shows how the same failure modes can be reproduced in a controlled environment and eliminated before code reaches production.


Why it happens

Reanimated 4 moves a subset of JavaScript code—the worklet—into a JSI (JavaScript Interface) environment that runs on the UI thread. The bridge between the normal JS runtime and the UI‑thread worklet has strict constraints:

Constraint What it enforces Typical failure when violated
Serialisation All values that cross the bridge must be serialisable by the Reanimated Babel plugin (primitives, plain objects, arrays). Map, Set, class instances, functions → Cannot serialize object of type errors at runtime, often only on release builds where the Babel plugin runs in “production” mode.
Deterministic execution Worklets must be pure: no side‑effects, no async calls, no await. Introducing a network request inside a worklet works in dev (because the dev packager keeps the JS thread alive) but crashes in release when the UI thread cannot resolve the promise.
Memory budget The UI thread shares the same heap as the rendering pipeline. Worklet closures that capture large objects (e.g., whole Redux state) inflate the UI‑thread heap. Under load, the UI thread hits GC pressure, leading to frame drops or OutOfMemoryError on low‑end devices.
Thread contention runOnJS posts a message to the JS thread; excessive use can saturate the JS event loop. When many worklets call runOnJS each frame, the JS thread becomes a bottleneck, causing animation stutter that appears only under real traffic.
Hermes vs JSC Hermes implements a different JSI layout; some native modules expose different symbols. Worklets that rely on a JSC‑specific native method work on iOS (JSC) but crash on Android (Hermes) in production.

The documentation covers the first two constraints, but the memory budget, thread contention, and runtime‑specific JSI differences are rarely demonstrated. They surface only when the app processes realistic numbers of concurrent gestures, large data payloads, or runs on a device with limited RAM.


The fix, step by step

Below is a reproducible workflow that catches the above failure modes before a release. Each step explains what to do, why it matters, and how to verify success.

1. Enforce strict worklet linting

What to do
Add the eslint-plugin-reanimated plugin and enable the no-unsupported-types rule.

npm i -D eslint-plugin-reanimated
# .eslintrc.js
module.exports = {
  plugins: ['reanimated'],
  rules: {
    'reanimated/no-unsupported-types': 'error',
  },
};
Enter fullscreen mode Exit fullscreen mode

Why
The rule flags usage of Map, Set, class instances, and functions inside a worklet block at lint time, preventing serialisation errors that only appear in release.

How to confirm
Run npm run lint. The output should contain no reanimated/no-unsupported-types violations. Add a deliberately unsupported line (e.g., new Map()) and confirm the linter catches it.

2. Measure worklet heap impact

What to do
Use Hermes’s hermes-profile to capture a heap snapshot while exercising the animation under realistic concurrency (e.g., 15 simultaneous swipe gestures).

adb shell am broadcast -a com.facebook.react.ACTION_PROFILE_HEAP -e duration 30
adb pull /sdcard/react-native-profile.heapsnapshot .
Enter fullscreen mode Exit fullscreen mode

Open the snapshot in Chrome DevTools (chrome://inspect) and look at the JSI heap section. Note the total size of objects whose __workletHash property is present.

Why
A worklet that captures the entire Redux store can easily add > 2 MB to the UI‑thread heap, which on a low‑end Android device (≈ 256 MB RAM) triggers frequent GC cycles.

How to confirm
Baseline a simple worklet (no captured state) – you should see < 200 KB. After adding your real worklet, the size should stay under 500 KB. If it exceeds this, refactor to pass only the needed primitives (e.g., value, index) instead of the whole object.

3. Stress‑test runOnJS frequency

What to do
Instrument the worklet to count how many times it calls runOnJS per frame.

let runOnJSCount = 0;
const worklet = () => {
  'worklet';
  runOnJSCount++;
  // existing animation logic
};
Enter fullscreen mode Exit fullscreen mode

In the native side, expose runOnJSCount via a custom native module and read it after a 5‑second stress test (e.g., using adb shell to trigger 30 simultaneous gestures).

// Android native module
@ReactMethod
public void getRunOnJSCount(Promise promise) {
  promise.resolve(ReanimatedModule.getRunOnJSCount());
}
Enter fullscreen mode Exit fullscreen mode

Why
If runOnJS is called more than 2–3 times per frame, the JS thread cannot keep up, leading to frame drops that only appear under load.

How to confirm
Run the test and check the reported count. It should stay ≤ 2 per frame (≈ 120 calls per second at 60 fps). If higher, move logic into the worklet or debounce the runOnJS calls.

4. Verify JSI compatibility across runtimes

What to do
Build two variants of the app:

  • iOS – using the default JSC runtime (RCT_ENABLE_HERMES=0).
  • Android – using Hermes (hermesEnabled=true in android/app/build.gradle).

Run the same automated UI test suite (e.g., Detox) on both platforms.

detox test -c ios.sim.release
detox test -c android.emu.release
Enter fullscreen mode Exit fullscreen mode

Why
Some native modules expose different symbols under Hermes vs JSC. Worklets that reference a missing symbol will crash only on the platform where it is absent.

How to confirm
All tests must pass on both variants. If a test fails on Android with an error like undefined is not a function (evaluating 'nativeModule.someMethod'), guard the call with if (global.HermesInternal) { … } or provide a JSC‑compatible fallback.

5. Run a release‑mode smoke test with the Babel plugin in production mode

What to do
Create a release build locally:

cd ios && pod install
xcodebuild -workspace MyApp.xcworkspace -scheme MyApp -configuration Release -derivedDataPath build
# Android
cd android && ./gradlew assembleRelease
Enter fullscreen mode Exit fullscreen mode

Install the APK/IPA on a device and enable JSI logging:

adb shell setprop debug.reanimated.logging true   # Android
# iOS: add `RCT_DEBUG=1` to scheme environment
Enter fullscreen mode Exit fullscreen mode

Trigger the animation and watch adb logcat | grep Reanimated for any Worklet execution failed messages.

Why
In development the Babel plugin runs in “dev” mode, which tolerates some serialisation edge cases. Production mode is stricter and will surface hidden bugs.

How to confirm
No Worklet execution failed lines should appear. If they do, the stack trace will point to the offending line; fix it (e.g., replace new Map() with a plain object) and repeat the test.

6. Add a CI gate for worklet size and runOnJS count

What to do
Create a small Node script that parses the generated *.js bundle (produced by Metro) for __workletHash entries and tallies captured variable sizes via a regex. Fail the build if any worklet exceeds 500 KB or if runOnJS appears more than 2 times per worklet.

// scripts/checkWorklets.js
const fs = require('fs');
const bundle = fs.readFileSync('index.android.bundle', 'utf8');
const workletRegex = /__workletHash:\s*['"](\w+)['"],\s*captured:\s*({[^}]*})/g;
let fail = false;
let match;
while ((match = workletRegex.exec(bundle)) !== null) {
  const captured = JSON.parse(match[2]);
  const size = Buffer.byteLength(JSON.stringify(captured));
  if (size > 500 * 1024) {
    console.error(`Worklet ${match[1]} captures ${size} bytes (>500 KB)`);
    fail = true;
  }
}
process.exit(fail ? 1 : 0);
Enter fullscreen mode Exit fullscreen mode

Add it to the CI pipeline:

- name: Check Reanimated worklets
  run: node scripts/checkWorklets.js
Enter fullscreen mode Exit fullscreen mode

Why
Automating the size and runOnJS checks guarantees that future contributors cannot unintentionally introduce regressions.

How to confirm
Push a PR that deliberately captures a large object; the CI should reject it with the error printed above.


What it costs, and when not to do it

Cost Description
Build‑time overhead Adding linting, heap‑snapshot capture, and CI checks adds ~5 minutes to the CI pipeline. On a large monorepo this can be noticeable, but the cost is linear and can be parallelised.
Bundle size The eslint-plugin-reanimated and extra source‑map comments increase the bundle by ~30 KB, negligible compared with the typical 5–10 MB React‑Native bundle.
Developer friction Enforcing strict serialisation may force developers to refactor existing code that passes complex objects to worklets. This can be mitigated by providing utility functions (toWorkletPayload) that strip unsupported fields.
Runtime performance The extra checks (e.g., counting runOnJS) are compile‑time only; they do not affect production performance.

When not to apply

  • Prototype or PoC – If the animation is a throw‑away demo that will never ship, the full suite of checks is overkill. A quick manual test on a single device may suffice.
  • Low‑traffic internal tools – If the app never exceeds a handful of concurrent gestures, the memory‑budget stress test may be unnecessary. However, keep the lint rule to avoid serialisation crashes.

In production‑grade apps that expose public APIs or run on a wide device matrix, the cost of these safeguards is far lower than the cost of a hot‑fix cycle or a crash‑related revenue loss.


At your level

Role Immediate take‑away One concrete next step
Student / first‑year engineer Worklets must only use plain data; serialisation errors are silent in dev. Run the eslint-plugin-reanimated linter on every commit.
Mid‑level engineer (feature owner) UI‑thread memory pressure and runOnJS contention are hidden failure modes. Add a heap‑snapshot check to your feature branch CI and keep worklet payloads under 500 KB.
Senior / staff engineer Architecture must treat worklets as a separate, size‑constrained module; cross‑runtime compatibility is a non‑negotiable contract. Introduce the CI gate for worklet size and runOnJS frequency across all repos, and mandate release‑mode smoke testing before any merge to main.
Lead / director The risk of production crashes scales with traffic; investing in automated detection saves weeks of hot‑fix effort. Allocate budget for a dedicated performance‑testing device farm that runs the release‑mode Reanimated smoke test on a matrix of low‑end Android and iOS devices.

In the interview

Typical interview prompt

“We have a React‑Native app that uses Reanimated 4 worklets for gesture‑driven animations. In production we started seeing occasional crashes on Android 12 devices with the message ‘Worklet execution failed: Cannot serialize object of type Map’. Walk me through how you would investigate and prevent this issue.”

Weak answer

“I would look at the stack trace, see that a Map is being passed, and change it to a plain object. Then I’d push a fix.”

Why it’s weak: it mentions the symptom but does not discuss why it only appears in production, ignores the broader constraints (memory, runOnJS), and shows no systematic detection strategy.

Strong answer

  1. Identify the constraint – Worklet‑to‑UI‑thread bridge requires serialisable data; Map is unsupported.
  2. Explain the production‑only aspect – In dev the Babel plugin runs in “dev” mode, which tolerates some non‑serialisable values; the release build enforces strict serialisation, causing the crash only after the app is bundled for production.
  3. Describe the detection – Add eslint-plugin-reanimated with no-unsupported-types, run a release‑mode smoke test on a device with debug.reanimated.logging enabled, and capture a heap snapshot to verify no large payloads.
  4. Quantify the impact – Show that each illegal type adds at least one serialisation error; on a device fleet of 10 k installs, a 0.5 % crash rate translates to 50 k crash reports.
  5. Mitigation – Refactor the worklet

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)