Reanimated Worklets: What Actually Runs on the UI Thread
How Reanimated worklets execute animation logic on the UI thread, bypassing the JS bridge. Covers the worklet runtime, shared values, and common pitfalls.

You ship a gesture-driven animation — a card that follows a finger, a shared element transition — and on a Pixel 5a it jitters. The JS thread is busy parsing a response, and the bridge queues your Animated.Value update behind a serialized batch. By the time it reaches the native driver, the frame is gone. React Native Reanimated avoids this by running animation logic on a separate thread that synchronises with the display vsync, but understanding what actually runs there — and what can't — is the difference between buttery motion and mysterious crashes.
The UI thread in React Native and why animations stall
In the classic React Native architecture (the one most production apps still run), two threads matter for rendering. The JS thread executes your application code: React reconciliation, business logic, network callbacks. The native/main thread handles touch events, rasterisation, and compositing. Between them sits the bridge — a serialized, asynchronous message queue. When the JS thread sets a component's opacity to 0.5, that value is JSON-stringified, sent across the bridge, and applied on the native thread on the next frame — assuming the JS thread isn't blocked.
Heavy JS work — parsing a large JSON payload, running a Redux selector over a deep state tree, executing a third-party library that does CPU-bound work — stalls the JS thread. While it's stuck, no new animation values cross the bridge. The frame deadline passes. The user sees a dropped frame, or worse, a sustained hitch.
Reanimated solves this by duplicating your animation logic onto a UI thread that runs its own JavaScript engine (JavaScriptCore or Hermes, depending on your build). This thread is not React's JS thread. It has its own event loop, its own memory heap, and direct access to the native animation drivers (CADisplayLink on iOS, Choreographer on Android). An animation defined in Reanimated does not touch the bridge at all for its per-frame updates — only for occasional synchronisation of shared values.
This architecture is a natural companion to the New Architecture with Fabric and TurboModules, which replaces the bridge with a more efficient JSI binding, but even without the New Architecture, Reanimated's UI thread bypasses the serialization bottleneck for animation work.
Worklets: functions that run on the UI thread
A worklet is a plain JavaScript function that has been marked for execution on the UI thread. You declare one with the 'worklet' directive — a string literal at the top of the function body — or implicitly by defining it inside a useAnimatedStyle, useAnimatedGestureHandler, or similar hook.
import { useSharedValue, useAnimatedStyle, withSpring } from 'react-native-reanimated';
function DraggableCard() {
const offset = useSharedValue(0);
const animatedStyle = useAnimatedStyle(() => {
'worklet';
return {
transform: [{ translateX: withSpring(offset.value, { damping: 15 }) }],
};
});
// ...
}The function passed to useAnimatedStyle is a worklet. It runs on the UI thread every frame that offset changes. The 'worklet' directive is not optional — without it, the function would execute on the JS thread and Reanimated would throw a warning or silently misbehave.
How does Reanimated know? At build time, a Babel plugin statically analyses functions containing the directive and extracts their source code into a string. At runtime, that string is evaluated inside the UI thread's JavaScript context. This is why worklets have strict limits on what they can reference: the source code is serialised and re-parsed in a separate engine instance. Lexical closures over React state or component-scoped variables simply don't survive the trip.
Reanimated's documentation describes this process in detail under Worklets. The key insight is that a worklet is not just a callback — it's a compiled unit of code that lives in a different runtime.
Shared values: the data transport between threads
If worklets run in a separate JavaScript context, how do you pass data from the JS thread — say, a fetched animation target — into the worklet? The answer is shared values, created with useSharedValue(initialValue).
const progress = useSharedValue(0);A shared value is a mutable reference backed by a thread-safe store that both the JS thread and the UI thread can read and write. When the JS thread writes progress.value = 42, the UI thread sees the new value synchronously — guaranteed — because the underlying store uses a shared memory primitive (or a JSI host object) rather than the bridge.
Inside a worklet, reading a shared value looks like a regular property access: progress.value. Writing to it is also straightforward: progress.value = newValue. All reads and writes inside a worklet are flushed to the native animation driver at the end of the current frame. You do not need to batch them manually.
Critically, shared values are reactive on the JS thread too. A component that reads a shared value via useDerivedValue or inside an useAnimatedStyle will re-render when that value changes — but only after the worklet has committed the frame. This decouples animation updates from React's render cycle, which is exactly the point.
Worklet runtime capabilities and restrictions
The UI thread's JavaScript context is not a full browser or Node environment. It has a deliberately constrained API surface. Here is what you can rely on inside a worklet:
| Category | Available | Unavailable |
|---|---|---|
| Primitives | Booleans, numbers, strings, undefined, null | Symbols, BigInt |
| Objects | Plain objects, Arrays, Map, Set | WeakMap, WeakSet, Proxy, Reflect |
| Math | All Math.* methods | — |
| Date | Date.now() | Date constructors with strings, getTimezoneOffset |
| Control flow | if, for, while, switch, try/catch | async/await, generators |
| Reanimated API | withSpring, withTiming, withDecay, interpolate | useAnimatedStyle, useSharedValue (hooks only) |
| Thread comms | runOnJS(), _WORKLET (boolean flag) | fetch, XMLHttpRequest, setTimeout, setInterval |
| Console | — | console.log (silent) |
The most common surprise for newcomers: console.log inside a worklet produces no output. The UI thread has no bound stdout. You must forward logging to the JS thread via runOnJS. Similarly, async functions are not available because the UI thread's event loop does not support microtask scheduling for user code — it processes one frame of worklets per vsync tick and discards exceeded work.
Common pitfalls: closures, serialization, and debugging
The serialisation mechanism that makes worklets possible also introduces sharp edges.
Capturing variables from the outer scope. A worklet cannot close over a let, const, or function parameter from the JS-thread scope — except for numbers, strings, booleans, and plain objects that are directly referenced and whose values are known at serialisation time. If you write:
function BadWorklet() {
const id = props.userId; // props is not serialisable
useAnimatedStyle(() => {
'worklet';
return { opacity: id === 5 ? 1 : 0 }; // crash or undefined
});
}props.userId is not available in the UI thread's context. The worklet will either throw at runtime or silently evaluate id as undefined. The fix is to pass the value via a shared value or a function argument using runOnUI.
Debugging silent failures. Because console.log does nothing in a worklet, you need a different strategy. Use runOnJS to forward debug information:
const logOnJS = useCallback((msg: string) => {
console.log('[UI thread]', msg);
}, []);
// Inside worklet:
runOnJS(logOnJS)(`offset is ${offset.value}`);Alternatively, set a breakpoint in the worklet source and inspect locals — the Babel plugin preserves the function body, so breakpoints in the original source file work when debugging on a device, provided you have source maps configured.
Calling runOnUI from JS with a non-worklet. runOnUI expects a function decorated with 'worklet'. Passing an ordinary closure will either fail to serialise or execute with an empty context. Always ensure the target function has the directive, or define it inside a useWorkletCallback scope.
runOnUI vs runOnJS: coordinating threads
Reanimated provides two functions for crossing the thread boundary explicitly.
runOnUI — called from the JS thread. It takes a worklet function and schedules it for execution on the UI thread. Useful when a gesture recogniser or a timeout fires on the JS thread and you need to kick off an animation imperatively.
import { runOnUI } from 'react-native-reanimated';
function startAnimation() {
runOnUI(() => {
'worklet';
someSharedValue.value = withSpring(100);
})();
}runOnJS — called from inside a worklet. It takes a JS-thread function and queues it for execution on the JS thread. This is the only way to trigger side effects — navigation, state updates, analytics — from inside a worklet.
const navigateToProfile = useCallback(() => {
navigation.navigate('Profile');
}, [navigation]);
// Inside a worklet:
runOnJS(navigateToProfile)();Both functions are asynchronous. They queue a job on the target thread's message loop. For runOnJS, the callback is guaranteed to run after the current frame's animation work has completed, not interleaved mid-frame. This means you cannot rely on the callback being called before the next useAnimatedStyle evaluation — design accordingly.
When not to use worklets (failure modes and limits)
Worklets are not a universal performance lever. They solve one specific problem: running animation-related logic synchronously with the display refresh. They are actively harmful for workloads that fall outside that scope.
Long-running computations. A worklet that loops through 10,000 items or performs heavy string processing will block the UI thread for the duration of the worklet call. If that exceeds the frame budget (roughly 16.6 ms at 60 fps), you drop frames — the same symptom you were trying to avoid. The UI thread does not background or preempt worklets mid-execution. Keep them tight.
Complex object operations. Deep cloning a large nested object inside a worklet may trigger GC pressure on the UI thread's heap, causing intermittent frame drops. Reanimated's serialisation of function arguments also has overhead; passing a large plain object to a worklet can be slower than you expect. If you need to transfer a complex data structure, consider transforming it on the JS thread and passing only the essential numeric values.
Frequent state-driven animations. If your animation target changes on every keystroke (e.g., a search box that animates suggestions as the user types), worklets help with the animation part but the JS thread still has to push new values into shared values. The bridge is not involved for the per-frame interpolation, but the JS thread must run React's render cycle and write the new target. If that render cycle itself is the bottleneck, worklets won't fix it — you need to optimise the JS-thread work first, perhaps by memoising components or deferring non-critical renders. For patterns that depend on reliable network state, consider Offline-First Sync with SQLite and REST in React Native to keep the JS thread free.
Key takeaways
- Worklets are JavaScript functions compiled to run on a dedicated UI thread with its own JavaScript engine, bypassing the React Native bridge entirely for per-frame animation updates.
- Shared values (
useSharedValue) are the only reliable way to exchange data between the JS thread and worklets — they are thread-safe and synchronously visible on the UI thread. - Worklets cannot access React state, props,
console.log, async APIs, or arbitrary closures. UserunOnJSfor side effects and debugging output. - Both
runOnUI(from JS to UI) andrunOnJS(from UI to JS) are asynchronous — they queue jobs on the target thread's event loop, not executed inline. - Worklets block the UI thread if they exceed the frame budget. Do not run long computations, large loops, or heavy object operations inside a worklet.
Frequently asked questions
- Why can't I use variables from outside a worklet?
- Worklets are serialized and executed in a separate JavaScript context on the UI thread. They have no access to the JS thread's closure scope. Only primitive values and plain objects passed as arguments or stored in shared values are available.
- Can I call any JavaScript function inside a worklet?
- No. Only a subset of JavaScript APIs are available: Math, basic array/object methods, Date.now, and Reanimated's animation functions. Async functions, fetch, DOM APIs, and console.log are not available. You can call runOnJS to invoke JS thread functions if needed.
- How do I debug worklet code?
- You cannot use console.log directly. Instead, use runOnJS(() => console.log(...)) to log to the JS thread. Alternatively, use Reanimated's logger by calling 'console.log' on a shared value or use the Reanimated DevTools plugin for Chrome DevTools.


