DEV Community

Cover image for Reanimated 4 worklets: enforcing a measurable performance budget
Amit chakraborty
Amit chakraborty

Posted on Originally published at amitchakraborty.dev

Reanimated 4 worklets: enforcing a measurable performance budget

The situation

You are the lead mobile engineer on a React Native product that ships to 200 k daily active users. The UI is heavily animated – drag‑and‑drop lists, gesture‑driven transitions and a custom physics engine are all implemented with Reanimated 4 worklets.

During a sprint review the QA team reports intermittent jank on low‑end Android devices: a swipe that should feel buttery now lags for up to 30 ms, causing the frame rate to dip from 60 fps to 30 fps. The crash logs show no native exceptions, only a spike in UIManager CPU usage.

The product manager asks the team to “make it faster”. The engineering manager asks for a concrete target so the team can defend the effort to leadership.

The cost of not acting is two‑fold: a measurable drop in user‑perceived performance (NPS fell by 0.3 points in the last week) and an upcoming release deadline that will be delayed while the team investigates the vague symptom.

The goal of this guide is to turn “make it faster” into a defensible performance budget for Reanimated 4 worklets and to give you a repeatable process for measuring, enforcing and iterating on that budget.


Why it happens

Reanimated 4 executes worklet code on the UI thread, bypassing the JavaScript bridge. This gives you 60 fps (≈ 16.67 ms per frame) for free, provided each worklet finishes well before the frame deadline. The main failure modes are:

Failure mode Why it hurts Typical threshold
Long‑running worklet A worklet that blocks the UI thread prevents the compositor from presenting the next frame. > 2 ms per worklet (empirical safe zone)
Frequent worklet scheduling Even short worklets become costly when scheduled many times per frame (e.g., inside a useAnimatedGestureHandler that fires on every pixel of a pan). > 4 worklet calls per frame
Heavy allocations Allocating objects inside a worklet forces a GC pause on the UI thread. > 5 allocations per worklet
Cross‑thread data marshalling Using runOnJS inside a worklet forces a bridge round‑trip, adding ≈ 3 ms latency. Any runOnJS call in a per‑frame worklet

The UI thread budget is a hard constraint: the compositor must receive a complete UI snapshot every 16.67 ms. If a worklet exceeds ~2 ms, you have only ~14.7 ms left for layout, painting and other system tasks. On low‑end devices the OS scheduler adds jitter, so the effective margin is even smaller.


The fix, step by step

Below is a reproducible, production‑ready workflow that turns a vague performance request into a concrete, enforceable budget.

1. Define the budget numerically

  1. Pick a target per‑frame budget.
    Why: 60 fps → 16.67 ms per frame. Reserve ~2 ms for each worklet to leave headroom for the rest of the pipeline.
    How to confirm: Run a baseline on a flagship device (e.g., Pixel 8, iPhone 15) and note the average frame time using react-native-performance or Android’s adb shell dumpsys gfxinfo. If the average is 13 ms, you have ~3.7 ms spare. Set the worklet budget to 2 ms.

  2. Document the budget in the repo.
    Create docs/performance-budget.md with a table:

   | Metric                | Limit   |
   |-----------------------|---------|
   | Worklet execution    | ≤ 2 ms  |
   | Worklet allocations  | ≤ 5 per call |
   | Worklet calls/frame  | ≤ 4 |
Enter fullscreen mode Exit fullscreen mode

2. Instrument worklet timings

Reanimated ships a now() function that returns a high‑resolution timestamp on the UI thread. Wrap every exported worklet with a timing helper.

  1. Create a helper file src/reanimated/perfHelper.ts:
   import { runOnUI, now } from 'react-native-reanimated';

   export const withTiming = (label: string, fn: () => void) => {
     'worklet';
     const start = now();
     fn();
     const duration = now() - start;

     // Log only when the threshold is exceeded to avoid noise.
     if (duration > 2) {
       console.log(`[Perf] ${label} took ${duration.toFixed(2)} ms`);
     }
   };
Enter fullscreen mode Exit fullscreen mode
  1. Apply the helper to a typical worklet, e.g., a pan gesture handler:
   import { useAnimatedGestureHandler } from 'react-native-reanimated';
   import { withTiming } from './perfHelper';

   export const usePanHandler = () => {
     return useAnimatedGestureHandler({
       onStart: (_, ctx) => {
         withTiming('pan.onStart', () => {
           ctx.startX = translateX.value;
         });
       },
       onActive: (event, ctx) => {
         withTiming('pan.onActive', () => {
           translateX.value = ctx.startX + event.translationX;
         });
       },
       onEnd: () => {
         withTiming('pan.onEnd', () => {
           // snap back animation
         });
       },
     });
   };
Enter fullscreen mode Exit fullscreen mode
  1. Verify the instrumentation by running the app on a low‑end Android device (e.g., Moto G Power) and looking for console output:
   [Perf] pan.onActive took 3.41 ms
Enter fullscreen mode Exit fullscreen mode

If you see any line, you have a violation.

3. Collect data across the codebase

  1. Run a performance test script on a CI runner that attaches a physical device via adb. Use the logcat filter:
   adb logcat -s ReactNativeJS | grep '\[Perf\]' > worklet-times.log
Enter fullscreen mode Exit fullscreen mode
  1. Parse the log to compute the 95th‑percentile duration per label:
   // parseLog.ts
   import fs from 'fs';

   const lines = fs.readFileSync('worklet-times.log', 'utf8')
                  .trim()
                  .split('\n')
                  .map(l => l.match(/\[Perf\] (\S+) took ([\d.]+) ms/))
                  .filter(Boolean) as RegExpMatchArray[];

   const stats: Record<string, number[]> = {};

   for (const [, label, ms] of lines) {
     if (!stats[label]) stats[label] = [];
     stats[label].push(parseFloat(ms));
   }

   for (const label in stats) {
     const arr = stats[label].sort((a, b) => a - b);
     const p95 = arr[Math.floor(arr.length * 0.95)];
     console.log(`${label}: 95th‑pct = ${p95.toFixed(2)} ms`);
   }
Enter fullscreen mode Exit fullscreen mode
  1. Fail the CI build if any label exceeds the budget:
   node parseLog.ts | awk '$NF > 2 {exit 1}'
Enter fullscreen mode Exit fullscreen mode

The CI job will now reject a PR that introduces a slow worklet.

4. Refactor offending worklets

When a worklet exceeds the budget, apply one of the following patterns:

Pattern When to use How it reduces time
Pre‑compute constants Values that never change (e.g., colour palettes) Move them out of the worklet; the worklet only reads a reference.
Chunk heavy loops A worklet that iterates over > 30 items Split the loop across frames using requestAnimationFrame‑style scheduling (setTimeout on UI thread is not available; instead use a stateful useDerivedValue that processes a slice each frame).
Memoise derived values Re‑calculations that depend on unchanged inputs Use useDerivedValue with a dependency array; the worklet runs only when inputs change.
Avoid runOnJS Any bridge call inside a per‑frame worklet Move the logic to the UI thread or batch updates and invoke runOnJS only on gesture end.

Confirm the fix by re‑running the instrumentation (step 2) and ensuring the console no longer prints a violation for that label.

5. Automate regression detection

  1. Add a lint rule (via eslint-plugin-react-native-reanimated) that flags any runOnJS call inside a worklet annotated with /* @per-frame */. The rule is optional but catches accidental regressions early.

  2. Update the CI pipeline to run the performance test on two device classes: a flagship and a low‑end device. The budget remains 2 ms, but you can optionally tighten the low‑end budget to 1.5 ms if the flag‑ship baseline is comfortably under the limit.

6. Communicate the budget to the team

Create a one‑page “Performance Charter” that lists:

  • The 2 ms per‑worklet ceiling.
  • The measurement methodology (console log, CI script).
  • The remediation patterns.
  • The escalation path (if a worklet cannot be optimised, consider moving the feature to the JS thread with a fallback UI).

Having this charter turns the vague “make it faster” request into a concrete, auditable commitment.


What it costs, and when not to do it

Cost Description
Developer time Adding the helper and CI script takes ~1 day for a medium‑size codebase. Ongoing refactors add ~0.5 day per sprint.
Runtime overhead The timing wrapper adds a few microseconds (≈ 0.1 ms) per worklet when the duration is under the threshold. This is negligible compared to the 2 ms budget.
Log noise On production builds you should disable the console.log path (wrap it in if (__DEV__)). Forgetting to do so can flood production logs.
Complexity Introducing a custom CI test and lint rule adds maintenance burden. If the app has only a handful of worklets, the ROI may be low.
False positives Occasionally a worklet spikes due to GC on a specific device; the CI script may flag it even though the average is fine. Mitigate by using the 95th‑percentile rather than max.

When not to apply:

  • If your app has < 5 worklets and the UI already runs at 60 fps on the target device range, the overhead of measurement outweighs the benefit.
  • If the product is a simple utility with no animations, a performance budget for worklets is unnecessary.
  • When the team is already over‑burdened with critical bugs, defer the budget implementation until a dedicated sprint for performance optimisation.

At your level

Role Take‑away One concrete next step
Student / first‑year engineer Understand that a worklet is not “free”; it competes for the same 16.67 ms frame budget as native rendering. Add the withTiming helper to a single worklet you wrote and verify the console output on a device.
Mid‑level engineer (ships features) You can enforce a budget without changing the core architecture; the CI script and helper are enough to keep the team honest. Add the CI performance test to your branch and make a PR that fails if any worklet exceeds 2 ms.
Senior / staff engineer The budget becomes a cross‑team contract; you should own the charter, the lint rule and the decision matrix for when to move work off the UI thread. Draft the “Performance Charter” and present it at the next architecture sync.
Lead / director The budget is a cost‑control lever: you can allocate engineering capacity based on measured violations rather than anecdotal complaints. Approve the CI budget test as part of the definition of done for any feature that adds a new worklet.

In the interview

Typical interview prompt

“We have a React Native app that uses Reanimated 4. Users report occasional jank on low‑end Android phones. How would you investigate and prevent this from happening again?”

Weak answer

  • “I would look at the code, try to optimise the animations, maybe move some work to the JS thread.”
  • Why it’s weak: It never quantifies the problem, never mentions the UI‑thread budget, and gives no concrete steps for verification or enforcement.

Strong answer

  1. Quantify the frame budget – 60 fps → 16.67 ms per frame, reserve ~2 ms per worklet.
  2. Instrument worklet execution using now() inside a wrapper that logs only when the duration exceeds the budget.
  3. Collect data on a representative low‑end device with adb logcat and compute the 95th‑percentile per worklet.
  4. Fail the build if any worklet exceeds the budget, and refactor using patterns (pre‑compute constants, memoise derived values, avoid runOnJS).
  5. Automate regression detection with a CI script and an optional lint rule to catch runOnJS inside per‑frame worklets.
  6. Document a performance charter so the team has a shared, measurable target.

What this shows: knowledge of the hard UI‑thread constraint, a reproducible measurement method, a concrete budget, and a process for continuous enforcement.

How the answer evolves with seniority

Seniority Expected depth
Junior Explain the 60 fps budget, show a simple timing wrapper, and mention “run on CI”.
Senior / Staff Add the 95th‑percentile analysis, discuss GC‑induced spikes, propose a lint rule, and outline a charter.
Director / Engineering Manager Emphas

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)