DEV Community

Amit chakraborty
Amit chakraborty

Posted on Originally published at amitchakraborty.dev

Reanimated Worklets: The Rule That Keeps Gestures Smooth

In my eight years of professional software engineering, I have seen a recurring failure mode in React Native performance: the misunderstanding of the bridge. Developers often reach for React Native Reanimated because they want "60 FPS animations," but they treat it like a standard utility library. They write complex logic, wrap it in a hook, and then inadvertently trigger a context switch that kills the frame rate.

During my time as the founding engineer at Synapsis Medical Technologies, I owned the React Native architecture from 0 to 1. When you are building a HealthTech AI platform that integrates wearables and real-time clinical data, the UI cannot stutter. A dropped frame in a standard consumer app is a nuisance; in a clinical environment, it erodes trust in the underlying data.

The most common culprit for these stutters is the misuse of runOnJS. If you are calling runOnJS inside a hot path—such as a gesture handler or a continuous animation loop—you are effectively undoing the primary reason Reanimated exists.

The Problem: The Asynchronous Tax

React Native traditionally operates across two main threads: the JavaScript (JS) thread and the UI (Native) thread. Communication between these threads happens over a bridge. This bridge is asynchronous, meaning if you send a message from JS to the UI thread, there is no guarantee exactly when it will arrive or be processed.

When you implement a gesture—like a swipe-to-dismiss or a complex pinch-to-zoom—the touch events originate on the UI thread. If your logic lives on the JS thread, every touch movement must be serialized, sent over the bridge, processed in JS, and then sent back to the UI thread to update the screen. If the JS thread is busy calculating a complex state update or handling a background network request, your animation lags.

Reanimated solved this by introducing "worklets"—small pieces of JavaScript that are compiled and executed directly on the UI thread.

The Technical Explanation: Anatomy of a Worklet

A worklet is a function marked with the 'worklet'; directive. When the Reanimated Babel plugin sees this, it captures the variables in the function's scope and allows it to be executed in a separate JavaScript VM on the UI thread.

function myWorklet(event) {
  'worklet';
  console.log("Running on the UI thread:", event.x);
}
Enter fullscreen mode Exit fullscreen mode

The magic of Reanimated is that it keeps the UI thread and the JS thread in sync regarding shared values. However, developers often hit a wall when they need to trigger a side effect that must happen on the JS thread, such as updating a React state or navigating to a new screen. This is where runOnJS comes in.

runOnJS is a gateway. It allows a worklet running on the UI thread to schedule a function execution back on the JS thread. The problem is that many developers treat this gateway as a transparent pipe. It is not. It is a heavy, asynchronous context switch.

Architecture and Trade-offs

At Synapsis Medical Technologies, I led the scaling of our engineering team from 0 to 21 engineers in 13 months. One of the core architectural standards I enforced was the "Hot Path Isolation" rule.

If a piece of logic is triggered by a PanGestureHandler or an onScroll event, it is in the hot path. In the hot path, every millisecond counts. A standard 60 FPS target gives you 16.6ms per frame. If you trigger runOnJS inside an onUpdate callback, you are forcing the engine to:

  1. Capture the current state.
  2. Serialize arguments.
  3. Wait for the JS thread to become idle.
  4. Execute the JS function.
  5. (Optionally) send a result back.

If your JS thread is occupied—perhaps by the HIPAA-aligned RAG/LLM pipelines I architected, which require significant overhead for data processing—the UI thread will continue to run, but your app logic will fall behind. This creates a "rubber-banding" effect where the visual element moves smoothly, but the application state (and subsequent UI changes) lags significantly.

The trade-off is clear: you gain the ability to use React state, but you lose the guarantee of synchronicity.

A Worked Example: The Scroll-to-Threshold Trap

Consider a common requirement: an interactive list where, once the user scrolls past 200 pixels, a "Back to Top" button appears.

The Anti-Pattern

A developer might write a worklet that checks the scroll offset and calls runOnJS to update a useState hook every time the value changes.

const onScroll = useAnimatedScrollHandler({
  onScroll: (event) => {
    if (event.contentOffset.y > 200) {
      runOnJS(setShowButton)(true); // Called every frame!
    } else {
      runOnJS(setShowButton)(false);
    }
  },
});
Enter fullscreen mode Exit fullscreen mode

In this scenario, runOnJS is fired 60 times per second while the user is scrolling. Even if the JS function setShowButton is simple, the overhead of the bridge communication will eventually saturate the message queue.

The Optimized Pattern

The correct approach is to keep the logic on the UI thread as long as possible and use DerivedValue or conditional execution to minimize bridge crossings.

const isPastThreshold = useDerivedValue(() => {
  return scrollOffset.value > 200;
});

useAnimatedReaction(
  () => isPastThreshold.value,
  (current, previous) => {
    if (current !== previous) {
      runOnJS(setShowButton)(current); // Only called when the state actually changes
    }
  }
);
Enter fullscreen mode Exit fullscreen mode

By using useAnimatedReaction, we move the comparison logic to the UI thread. runOnJS is now only invoked once when the threshold is crossed, rather than on every single pixel of movement.

What it Cost to Learn

Building 18+ production applications across iOS, Android, and web has taught me that performance is rarely about one big mistake; it is the accumulation of small inefficiencies.

In my work as an independent Systems Architect, I often audit codebases where the "slow" feeling of the app is attributed to React Native itself. In reality, the issue is usually a high-frequency runOnJS call inside a gesture handler.

When I overhauled the CI/CD across five production systems to cut release cycles from 2 days to 4 hours, one of the primary benefits was the ability to run automated performance regression tests more frequently. We learned that bridge traffic is the leading indicator of UI jank. If the bridge traffic spikes during a gesture, the user experience is already compromised.

In the HealthTech space, where I managed RAG pipelines with 99.9% uptime, we had to be even more disciplined. When dealing with clinical AI data, you cannot afford for the UI to be unresponsive because a background data sync is hogging the JS thread. We had to ensure that the UI thread remained completely autonomous for all gesture-driven interactions.

Practical Recommendations

To maintain smooth gestures, follow these three rules:

  1. Calculate, Don't Communicate: If you can calculate a value on the UI thread using useDerivedValue, do it. Do not pass raw gesture data back to the JS thread for processing.
  2. Debounce or Gate runOnJS: Never call runOnJS inside onUpdate without a conditional gate. Use a shared value to track the "last sent state" and only call the JS thread when a meaningful change occurs.
  3. Use useAnimatedReaction for Side Effects: Instead of putting side-effect logic inside your gesture handler, use useAnimatedReaction. This separates the gesture math (which must be fast) from the application logic (which can be slower).

Conclusion

Reanimated worklets are a powerful tool for bypassing the React Native bridge, but they are not a silver bullet. The moment you use runOnJS, you are stepping back onto the bridge. By isolating your hot paths and strictly limiting the frequency of thread context switches, you ensure that your gestures remain fluid, regardless of how much heavy lifting your JS thread is doing in the background. Whether you are building clinical AI tools or consumer apps, the rule remains the same: keep the UI thread for the eyes, and the JS thread for the brain.


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)