Cleanly separating the UI thread from the JS thread
Worklets are the foundation of Reanimated: small JavaScript functions transformed by a Babel plugin so they run directly on the UI thread, independent of how busy the JS thread is. Understanding why Reanimated is fast, and where runOnJS and runOnUI actually belong, requires knowing this execution model in detail, not just memorizing the finished hooks.
Table of Contents
- 1. Two execution contexts, one rendering target
- 2. The worklet directive and the Babel plugin
- 3. What actually happens when a worklet is compiled
- 4. Closures in worklets: values are copied, not referenced
- 5. runOnJS: safely hopping back from the UI thread to the JS thread
- 6. runOnUI: sending code to the UI thread on purpose
- 7. Common mistakes: reading JS-thread values inside a worklet
- 8. Debugging worklets: logging, sourcemaps, and error analysis
- 9. Practical pattern: a scroll handler with zero bridge overhead
- 10. Summary
- 11. FAQ
1. Two execution contexts, one rendering target
React Native has always separated app logic from the actual drawing of the interface. The JS thread runs component rendering, state updates, and business logic, while the UI thread (the main thread on iOS, the native UI thread on Android) handles actual layout and drawing of native views. Before JSI, every bit of communication between the two sides had to travel over the asynchronous, serializing bridge, which was a noticeable time budget problem for animations running at 60 frames per second.
Reanimated solves this by no longer sending animation logic as a serialized message across the bridge, but by running compiled code directly on the UI thread. That is possible thanks to JSI, which allows synchronous function calls between JavaScript and native code. The effect: an animation stays smooth even while the JS thread is re-rendering a large list or running an expensive computation, because the UI thread is fully decoupled from that work.
2. The worklet directive and the Babel plugin
A function becomes a worklet either implicitly, because a Reanimated API like useAnimatedStyle treats its callback that way, or explicitly, by marking it with the 'worklet' directive as the first line of the function body. Reanimated's Babel plugin recognizes this marker at build time, extracts the function body, and attaches a compiled representation that can be executed directly on the UI thread.
For this to work reliably, the plugin entry in babel.config.js must be the last item in the plugins array, since it needs to analyze the code after every other transform has already run. If it is missing or placed incorrectly, the app still compiles, but throws cryptic runtime errors about missing functions on the UI thread the moment a worklet actually executes.
// babel.config.js
module.exports = {
presets: ['module:metro-react-native-babel-preset'],
plugins: [
// Must be the LAST entry so every other transform has
// already run by the time this plugin analyzes the code.
'react-native-reanimated/plugin',
],
};
3. What actually happens when a worklet is compiled
At build time, the Babel plugin extracts a worklet's function body as a source string, determines which closure variables it uses, and attaches metadata such as a worklet hash and the serialized initial closure values. At runtime, the Reanimated runtime compiles that string into executable code once, on the UI thread, usually through Hermes, which runs on both threads and thereby guarantees identical language behavior.
This mechanism implies an important limitation: a worklet cannot arbitrarily import modules or use complex class instances from the surrounding scope, because only what the JSI bridge can represent gets serialized, essentially primitives, arrays, objects, and shared values. Trying to use a large external library directly inside a worklet usually fails right at compile time.
4. Closures in worklets: values are copied, not referenced
When a worklet is created, Reanimated copies the closure variables visible at that moment into the serialized representation. A regular React state or prop value read inside the worklet therefore only reflects the value from the last render that recreated the worklet, and does not update automatically when that state changes afterward. This surprises many developers coming from the classic React world, where closures behave like live references.
A shared value behaves differently, because it is not the raw value but the shared-value object with its .value property that gets copied into the closure. Since that object points to the same underlying storage on both threads, reading .value inside the worklet always returns the current value, regardless of when the worklet was originally created. The rule of thumb: anything that needs to change over a worklet's lifetime belongs in a shared value.
function Example({ threshold }: { threshold: number }) {
// Wrong: 'threshold' is copied when the worklet is created.
// If the prop changes later, the worklet still sees the old value.
const staleStyle = useAnimatedStyle(() => {
return { opacity: offset.value > threshold ? 1 : 0 };
});
// Right: keep the threshold itself in a shared value.
const thresholdSV = useSharedValue(threshold);
useEffect(() => {
thresholdSV.value = threshold;
}, [threshold]);
const liveStyle = useAnimatedStyle(() => {
return { opacity: offset.value > thresholdSV.value ? 1 : 0 };
});
return <Animated.View style={liveStyle} />;
}
5. runOnJS: safely hopping back from the UI thread to the JS thread
As soon as a worklet running on the UI thread needs to trigger something that only exists on the JS thread, a React state update, a navigation call, an analytics event, runOnJS comes into play. It schedules the given function call asynchronously on the JS thread's queue, serializing the arguments in a way similar to a structured clone so they survive the thread hop intact.
It is worth noting that runOnJS does not return a value to the calling worklet and does not execute synchronously. Calling runOnJS on every single animation frame, say on every pixel of scroll offset, floods the JS thread's queue and undermines exactly the performance advantage Reanimated is supposed to deliver. In practice, runOnJS belongs at the end of an interaction, not inside a running animation loop.
const gesture = Gesture.Pan()
.onUpdate((event) => {
translateX.value = event.translationX;
})
.onEnd(() => {
translateX.value = withSpring(0);
// Cross to the JS thread ONCE at the end of the gesture,
// not on every single onUpdate call.
runOnJS(trackSwipeCompleted)();
});
6. runOnUI: sending code to the UI thread on purpose
runOnUI is the mirror image of runOnJS: JS-thread code, say a button handler or an effect, uses it to send a worklet call to the UI thread on purpose. This is needed less often than runOnJS, since most animations already trigger automatically through shared value changes, but it is essential for imperative APIs such as measuring a view with measure() or kicking off an animation outside the normal render cycle.
A typical use case is a layout measurement after a button press, where the current position and size of a view is needed on the UI thread before an animation built on top of it can start. Since measure() is itself a worklet, it must be called either from an existing worklet context or explicitly through runOnUI.
const animatedRef = useAnimatedRef<Animated.View>();
function measureAndAnimate() {
runOnUI(() => {
'worklet';
const layout = measure(animatedRef);
if (layout === null) {
return;
}
scale.value = withTiming(layout.width > 200 ? 1.2 : 1);
})();
}
7. Common mistakes: reading JS-thread values inside a worklet
The most frequent mistake is accessing a ref object (say a plain useRef) or a useState value inside a worklet, expecting changes to show up live. Since neither is synchronized across the JSI bridge, the worklet either sees a frozen initial value or throws when it tries to access a current field that does not exist in the worklet context at all.
A second classic mistake is calling an imported helper function, say from a utility file or a library, that was never compiled as a worklet itself. Reanimated then reports at runtime that the function cannot be called synchronously on the UI thread. The fix is to mark the helper function with the 'worklet' directive too, or move the logic into a Reanimated utility that is already recognized as a worklet.
A third, subtler mistake is assuming that console.log inside a worklet appears immediately and in order with JS-thread logs. In reality, the output is mirrored back to the JS thread through a proxy and can therefore show up delayed or out of order in the terminal, which can lead to wrong conclusions about actual execution order while debugging.
8. Debugging worklets: logging, sourcemaps, and error analysis
Reanimated ships a configurable logger, set up through configureReanimatedLogger, that controls warning levels and behavior for classic pitfalls, such as reading a shared value directly during a normal React render instead of inside a worklet. These warnings are gold during development, because they cover exactly the cases that would otherwise only show up as a hard-to-reproduce bug in the app later.
Errors thrown inside a worklet come with a reasonably well symbolicated stack trace in recent Reanimated versions, resolved through Metro. On older setups or production builds without sourcemaps, only a minified stack trace remains, so it pays to first test critical animation logic in a development build with symbolication enabled before it ships in a release build.
9. Practical pattern: a scroll handler with zero bridge overhead
A realistic example of cleanly separated responsibilities is a scroll handler that hides and shows a header while scrolling, but only fires an analytics event on the JS thread once the scroll gesture ends. The offset itself stays entirely on the UI thread, while runOnJS is used deliberately at the one point where it is actually needed.
This pattern, keeping as much logic as possible inside worklets and only involving the JS thread at clearly defined transitions, is the core of every performant piece of Reanimated code. Hopping back and forth between the threads on every small change throws away exactly the advantage the whole worklet model was built to provide.
const headerVisible = useSharedValue(true);
let lastOffset = 0;
const scrollHandler = useAnimatedScrollHandler({
onScroll: (event) => {
const current = event.contentOffset.y;
headerVisible.value = current < lastOffset || current < 20;
lastOffset = current;
},
onEndDrag: () => {
runOnJS(trackScrollSettled)(lastOffset);
},
});
const headerStyle = useAnimatedStyle(() => ({
transform: [{ translateY: withTiming(headerVisible.value ? 0 : -80) }],
}));
| Task | Runs on | Recommended API | Common pitfall |
|---|---|---|---|
| Derive a style from a shared value | UI thread | useAnimatedStyle |
Reading state directly instead of via .value |
| State update after a gesture | JS thread | runOnJS(setState) |
Forgetting runOnJS, app crashes |
| Call an existing instance method | UI thread | runOnUI(fn)() |
Target function is not a worklet |
| Evaluate scroll position live | UI thread | useAnimatedScrollHandler |
Using the classic onScroll callback instead of a worklet |
| Send an analytics event after an animation | JS thread | runOnJS(trackEvent) |
Trying a network call directly inside a worklet |
| Measure a view's layout | UI thread | measure(animatedRef) |
Measuring on the JS thread with stale layout data |
Mironsoft
React Native app development and Magento integration
A mobile app for the Magento shop that actually runs smoothly?
We build React Native apps cleanly connected to the Magento REST or GraphQL API, from the first line of code to publishing on the App Store and Google Play.
App Concept
Plan the architecture and feature scope of a Magento-connected app together.
Magento API Integration
Cleanly connect product catalog, cart, and checkout to the shop API.
Store Publishing
Guide the App Store and Google Play release process without pitfalls.
10. Summary
Reanimated Worklets
Core idea
Worklets run as compiled code directly on the UI thread, independent of JS thread load.
runOnJS
Schedules a call asynchronously from the UI thread back to the JS thread, say for state updates.
runOnUI
Sends a worklet call deliberately from the JS thread to the UI thread, say for imperative measurements.
Biggest pitfall
Closures copy values at creation time, so state and props without a shared value stay frozen inside the worklet.