DEV Community

Cover image for Unexpected UI‑Thread Jank When Using Reanimated 4 Worklets
Amit chakraborty
Amit chakraborty

Posted on Originally published at amitchakraborty.dev

Unexpected UI‑Thread Jank When Using Reanimated 4 Worklets

The situation

You are a mobile engineer on a small React‑Native team (React Native 0.73, Reanimated 4.5). The sprint goal is to implement a draggable carousel with smooth physics. Following a popular tutorial you add a useAnimatedGestureHandler worklet that reads a global constant, calls a utility function from a shared module, and updates a sharedValue. In development on a high‑end device the animation feels fluid, but after the feature ships to production two weeks later users on mid‑range Android phones (e.g., Snapdragon 730) start reporting “janky scrolling” and occasional “app freeze” after a few minutes of use. Crash logs show:

Error: Worklet threw an exception
ReferenceError: Cannot read property 'duration' of undefined
    at worklet (worklet.js:12:23)
    at __callWorklet (ReanimatedModule.js:215)
Enter fullscreen mode Exit fullscreen mode

The incident forces the team to roll back the feature, delaying the release by a week and costing the product team a planned marketing push.

Below is a concrete, production‑tested path from that symptom to a stable implementation, including the hidden costs of worklet‑level decisions that tutorials rarely mention.


Why it happens

Reanimated 4 executes worklets on the UI thread via the JavaScript Interface (JSI). This gives you frame‑perfect animations, but it also imposes several constraints that become failure points under real‑world load:

Constraint What it means Typical failure mode
Isolated execution context Worklets run in a sandbox that only exposes a subset of the JavaScript runtime (no console, no fetch, no require of native modules). Accessing a variable that lives only in the normal JS runtime (e.g., a global constant defined in App.js) throws a ReferenceError.
Single UI‑thread All worklets share the same UI thread as the native UI pipeline. Heavy calculations or synchronous I/O inside a worklet block the UI thread, causing frame drops (jank).
Bundle size impact Worklet code is inlined into the native binary at build time. Large utility libraries (e.g., lodash) imported into a worklet inflate the APK/IPA (up to 1 MB per 10 kB of worklet code) and increase start‑up time.
Garbage‑collection pressure The UI thread runs its own GC; frequent allocation of objects inside worklets leads to GC spikes. Intermittent freezes after a few minutes, especially on low‑memory devices.
Debugging barrier Errors are marshalled back to the JS thread as generic messages; source‑maps for worklet code are optional. The stack trace points to the generated worklet file, not the original source, making root‑cause identification slow.

These constraints are documented, but the documentation does not tell you which of the many possible worklet patterns survive production, how to measure the impact, or what the hidden cost in memory and bundle size actually looks like.


The fix, step by step

Below is a reproducible, production‑ready workflow that eliminates the jank and the ReferenceError while keeping the animation fluid. The steps assume a fresh clone of the repository, Node 18, Yarn 1.22, and the same version numbers as above.

1. Isolate worklet code from the normal JS bundle

What to do
Create a dedicated directory src/worklets/ and move only the pure‑calculation code there. Do not import any UI components, React hooks, or third‑party libraries that depend on the full JS runtime.

mkdir -p src/worklets
mv src/utils/physics.js src/worklets/physics.worklet.js
Enter fullscreen mode Exit fullscreen mode

Why
Only the code that can be compiled to a worklet (i.e., functions that use only the Reanimated API, primitive JS, and other worklet‑compatible modules) will be inlined. This prevents accidental capture of globals and reduces bundle bloat.

How to confirm
Run the Metro bundler with the --verbose flag and look for a line like:

[Reanimated] Worklet file compiled: src/worklets/physics.worklet.js (size: 3.2 KB)
Enter fullscreen mode Exit fullscreen mode

If the file size is > 30 KB, you are pulling in unintended dependencies; proceed to step 2.

2. Replace heavy utility libraries with hand‑rolled equivalents

What to do
If physics.worklet.js imports lodash (e.g., _.clamp), replace it with a tiny inline implementation:

// before
import clamp from 'lodash/clamp';

// after
function clamp(value, min, max) {
  'worklet';
  return Math.min(Math.max(value, min), max);
}
Enter fullscreen mode Exit fullscreen mode

Why
lodash adds ~12 kB per import to the worklet bundle, inflating the native binary and increasing memory pressure on the UI thread.

How to confirm
Re‑run the bundler and verify the worklet size drops (e.g., from 28 KB to 4 KB). The build log will show the new size.

3. Guard against undefined globals

What to do
Any constant used inside a worklet must be passed explicitly as a parameter or defined inside the worklet file. For the carousel duration, move the constant into the worklet:

// src/constants.js (normal JS)
export const CAROUSEL_DURATION = 300;

// src/worklets/carousel.worklet.js
import { CAROUSEL_DURATION } from '../constants'; // ❌ will be undefined at runtime

// Fix: pass as argument
export const carouselWorklet = (duration) => {
  'worklet';
  return (event) => {
    // use duration safely
  };
};
Enter fullscreen mode Exit fullscreen mode

Then bind it when creating the gesture handler:

const duration = CAROUSEL_DURATION; // normal JS
const gesture = useAnimatedGestureHandler(
  carouselWorklet(duration)
);
Enter fullscreen mode Exit fullscreen mode

Why
Worklet code cannot resolve imports that resolve to non‑worklet modules at runtime; the import becomes undefined, leading to the ReferenceError seen in logs.

How to confirm
Trigger the gesture on a device with adb logcat (Android) or Xcode console (iOS). The error should no longer appear. Instead you should see a clean log entry like:

[Reanimated] worklet executed successfully (duration: 300)
Enter fullscreen mode Exit fullscreen mode

4. Off‑load heavy math to a background thread

What to do
If the physics simulation requires iterative calculations (e.g., a spring solver), move it to a runOnJS call that executes on a separate JS thread, then feed the result back to the UI thread via a sharedValue.

const springResult = useSharedValue(0);

const computeSpring = (value) => {
  // heavy calculation
  let result = value;
  for (let i = 0; i < 1e5; i++) result = result * 0.99 + Math.random() * 0.01;
  return result;
};

useAnimatedReaction(
  () => sharedValue.value,
  (newVal) => {
    runOnJS(() => {
      const res = computeSpring(newVal);
      springResult.value = res;
    })();
  }
);
Enter fullscreen mode Exit fullscreen mode

Why
runOnJS schedules the heavy work on the normal JS thread, preserving UI‑thread frame budget. The UI thread only reads the final scalar, avoiding GC spikes.

How to confirm
Profile the UI thread with Android Studio’s CPU Profiler or Xcode’s Instruments. The UI thread’s CPU usage should stay below 10 % during a drag, whereas previously it spiked to ~30 % during the same interaction.

5. Enable worklet source‑maps for debugging

What to do
Add the following to babel.config.js (if not already present):

module.exports = {
  presets: ['module:metro-react-native-babel-preset'],
  plugins: [
    ['react-native-reanimated/plugin', { globals: ['__workletHash'] }],
  ],
};
Enter fullscreen mode Exit fullscreen mode

Then rebuild:

cd ios && pod install && cd ..
yarn react-native run-android --variant=release
Enter fullscreen mode Exit fullscreen mode

Why
Source‑maps allow the error stack to point back to the original TypeScript/JS file, making future debugging faster.

How to confirm
Re‑produce the error (e.g., by deliberately passing undefined to the worklet). The console should now display:

Error: Worklet threw an exception
ReferenceError: Cannot read property 'duration' of undefined
    at carouselWorklet (src/worklets/carousel.worklet.ts:23:15)
Enter fullscreen mode Exit fullscreen mode

6. Verify bundle size and memory impact

What to do
Run the bundle analyzer:

npx react-native-bundle-visualizer --platform android
Enter fullscreen mode Exit fullscreen mode

Look for the Reanimated worklet section. The total size should be under 150 KB for all worklets combined. If it exceeds 300 KB, revisit steps 1‑2.

Why
Large worklet bundles increase the native binary, leading to longer cold starts and higher memory usage, which is a common cause of out‑of‑memory kills on low‑end devices.

How to confirm
Check the generated app-release.apk size before and after the changes. Expect a reduction of ~5 % (≈ 2 MB for a 40 MB app).

7. Run a synthetic load test

What to do
Automate a drag sequence with Appium or Detox that repeats the gesture for 30 seconds on an emulator configured with 2 GB RAM. Record frame times via adb shell dumpsys gfxinfo <package>.

Why
Synthetic load surfaces GC spikes and UI‑thread blockage that only appear after a few minutes of real use.

How to confirm
The average frame time should stay below 16 ms (60 fps). If you see a trend toward > 30 ms, revisit step 4 to further off‑load work.


What it costs, and when not to do it

Action Approximate cost When to skip
Separate worklet directory & compile‑time isolation +0.5 h developer time, +0 KB runtime (once set up) If the feature is a one‑off prototype never shipped.
Replacing lodash with hand‑rolled helpers +1 h per library, +0 KB runtime When the library is already tiny (≤ 1 kB) and you lack the bandwidth to maintain custom code.
Passing constants as arguments Negligible runtime cost, +0.2 h code review If the constant is truly static and you can guarantee the worklet will never be hot‑reloaded (rare).
Off‑loading heavy math to runOnJS +0.5 h for refactor, extra JS‑thread CPU usage (still < 10 % UI) When the calculation is trivial (< 10 µs) and the added indirection hurts readability.
Enabling source‑maps +0.1 h config, +~10 KB bundle (metadata) In production builds where you deliberately strip source‑maps for security (rare for mobile apps).
Bundle‑size analysis +0.2 h per release If your app is already under 50 MB and you have no low‑end device users.
Synthetic load test +1 h test‑script maintenance, requires CI resources For internal tools that never reach end‑users.

When not to use worklets at all
If the animation does not require sub‑16 ms precision (e.g., a simple fade‑in) or if the UI thread is already saturated by other native modules (e.g., heavy video decoding), consider using the native Animated API or a pure‑JS solution. Worklets shine when you need deterministic frame‑by‑frame control; otherwise the hidden costs outweigh the benefit.


At your level

Role Take‑away One concrete next step
Student / first‑year engineer Worklet code runs on the UI thread and cannot see normal JS globals. Move any constant used in a worklet into the worklet file or pass it as a parameter; verify with a quick console test.
Mid‑level engineer (ships features) Heavy computation inside a worklet blocks the UI thread, leading to jank on mid‑range devices. Profile UI‑thread CPU usage with Android Studio’s profiler after adding a new worklet; if usage exceeds 15 %, refactor the heavy part into runOnJS.
Senior / staff engineer (architect) Worklet bundle size directly inflates the native binary and raises memory pressure; source‑maps are essential for production debugging. Add a nightly CI step that runs react-native-bundle-visualizer and fails the build if total worklet size > 150 KB.
Lead / director (budget & roadmap) The hidden cost of worklet misuse is a delayed release and a potential loss of market share on low‑end devices. Allocate a 2‑day spike in the next sprint for “Reanimated worklet audit” across all active screens, and track the reduction in APK size and crash rate as a KPI.

In the interview

Typical interview prompt

“We have a React‑Native app that uses Reanimated 4 worklets for a drag‑and‑drop list. After a recent release, users on Android 10 devices report intermittent jank and occasional freezes. Walk me through how you would investigate and fix this.”

Weak answer

“I’d just look at the code and maybe move the heavy calculations out of the worklet. Worklets are fast, so it’s probably a bug in the library.”

Why it’s weak: it never identifies the specific constraints of worklet execution, does not mention the isolated context, bundle‑size impact, or how to verify the fix. It also lacks measurable trade‑offs.

Strong answer

  1. Identify the constraints – explain that worklets run on the UI thread in a sandbox, so any reference to a normal‑JS global will be undefined and heavy synchronous work will block rendering.
  2. Gather metrics – run adb logcat to capture the exact error, use the Android Studio CPU profiler to measure UI‑thread usage, and run react-native-bundle-visualizer to check worklet bundle size.
  3. Apply the fix – move constants into the worklet or pass them as arguments, replace large utility imports with lightweight equivalents, and off‑load any O(N) calculations to runOnJS. Enable source‑maps for better stack traces.
  4. Quantify the improvement – show that UI‑thread CPU dropped from ~25 % to < 8 %, frame time stayed under 16 ms, and worklet bundle size reduced by ~30 KB,

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)