Last Tuesday I bumped an Expo SDK 53 project from [email protected] to [email protected] for a client app — a fitness tracker with about a dozen gesture-driven screens. The Metro bundler was happy. npx expo prebuild worked. The iOS build compiled. Then I opened the app and the splash screen sat there for forty seconds before the JS context threw "ReanimatedError: Failed to create a worklet — react-native-worklets is not installed" and crashed back to the home screen.
If you have hit the same wall — or you are about to upgrade and want to do it once rather than three times — this is the migration I wish someone had written before I spent an afternoon staring at the Reanimated changelog. As of May 2026, Reanimated 4 has been stable for about ten months, but the worklets split still trips up almost every Expo project I touch because the install steps in the README assume bare React Native, not Expo's managed workflow.
Why Reanimated 4 broke your worklets
Worklets used to be an internal implementation detail of Reanimated. You wrote 'worklet' at the top of a function, the babel plugin serialised it, and the UI runtime ran it. That whole machine — the babel plugin, the C++ runtime, the runOnUI / runOnJS bridge — lived inside react-native-reanimated.
In Reanimated 4 the Software Mansion team extracted the worklets runtime into its own package, react-native-worklets. The reasoning, summarised from their RFC, is that other libraries — Gesture Handler, Skia, Audio API — also want to run JS on a separate runtime without dragging in the entire animation engine. Splitting it out lets those libraries depend on the worklets primitive directly.
The practical consequence for us is that react-native-reanimated@4 now has react-native-worklets as a peer dependency you must install yourself. npm and yarn will warn about the missing peer; pnpm will refuse to resolve it; and Expo's autolinking will not magically add it for you. If you ignore the warning and ship, the JS bundle loads but the first call to useSharedValue or any 'worklet'-tagged function throws at runtime — exactly the crash I hit.
The Expo SDK 53 install that actually works
Here is the install sequence I now run on every Expo SDK 53 project. The order matters because the babel plugin moves between packages and Metro will silently use a cached transform if you skip the cache reset.
npx expo install react-native-reanimated@~4.0.1
npx expo install react-native-worklets@~0.6.0
# Stop Metro, clear caches, then prebuild fresh
watchman watch-del-all 2>/dev/null || true
rm -rf node_modules/.cache .expo ios/build android/build
npx expo prebuild --clean
# Reinstall pods because the worklets package ships native code
cd ios && pod install && cd ..
Use npx expo install rather than npm install directly — the Expo CLI checks the version table at docs.expo.dev/versions/latest/sdk/reanimated/ and pins the version Expo has actually tested against SDK 53. As of this writing the supported pair is Reanimated 4.0.1 with Worklets 0.6.0; mismatched minor versions will compile but produce strange behaviour on Android (animations skipping the first frame is the giveaway).
Updating babel.config.js
This is the change that bites most people. In Reanimated 3 your babel config ended with 'react-native-reanimated/plugin'. In Reanimated 4 that plugin still exists but it is a thin shim — the real work is done by the worklets plugin, which must come from the new package and must be the last item in the plugins array.
// babel.config.js
module.exports = function (api) {
api.cache(true);
return {
presets: ['babel-preset-expo'],
plugins: [
// ...your other plugins (expo-router, nativewind, etc.)
'react-native-worklets/plugin', // MUST be last
],
};
};
If you forget to move the plugin and leave the old react-native-reanimated/plugin entry, Metro will transform your worklets twice and you will see "Tried to synchronously call function from a different thread" on the first render. Remove the old line entirely.
Migrating runOnUI and runOnJS calls
Most worklet code keeps working without changes — useSharedValue, useAnimatedStyle, withTiming, and the gesture handlers all still live in react-native-reanimated. The thread-crossing helpers, though, moved. runOnUI and runOnJS are now exported from react-native-worklets, and the old import path is deprecated with a warning that becomes a hard error in Reanimated 5.
Here is a before/after for a typical haptic-on-swipe pattern from the fitness app:
// Before (Reanimated 3)
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
runOnJS,
} from 'react-native-reanimated';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import * as Haptics from 'expo-haptics';
// After (Reanimated 4 + Worklets 0.6)
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
} from 'react-native-reanimated';
import { runOnJS } from 'react-native-worklets';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import * as Haptics from 'expo-haptics';
export function SwipeCard() {
const translateX = useSharedValue(0);
const triggerHaptic = () => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
};
const pan = Gesture.Pan()
.onUpdate((e) => {
'worklet';
translateX.value = e.translationX;
})
.onEnd((e) => {
'worklet';
if (Math.abs(e.translationX) > 120) {
runOnJS(triggerHaptic)();
}
translateX.value = withSpring(0);
});
const style = useAnimatedStyle(() => ({
transform: [{ translateX: translateX.value }],
}));
return (
<GestureDetector gesture={pan}>
<Animated.View style={[styles.card, style]} />
</GestureDetector>
);
}
The diff is small but easy to miss across a large codebase. I wrote a one-line jscodeshift to do it project-wide, but for most apps a regex find-and-replace across the src/ tree is enough. If you want the safer route, our jscodeshift migration walkthrough covers the AST approach.
Gesture Handler 2.20 quietly switched runtimes too
One trap that cost me an hour: if you are on [email protected] or newer, the gesture callbacks now run on the worklets runtime, not the Reanimated runtime. They are the same thing in practice — both are the UI thread — but if you have any conditional logic that checks _WORKLET or uses the older __reanimatedWorkletInit sentinel, it will be undefined inside gesture callbacks. Use the simple 'worklet' directive and let the babel plugin handle the rest.
The silent Hermes bytecode trap
This caught me on the second app I migrated, an EAS-built production binary. Locally everything worked. The EAS preview build crashed on launch with the same "react-native-worklets is not installed" error, even though the dependency was clearly in package.json and Podfile.lock.
The culprit was Hermes bytecode caching. EAS Build caches the node_modules Hermes precompile step keyed on a lockfile hash, and the cache key did not invalidate when I changed the babel plugin order (the lockfile was identical because I had already installed worklets earlier). The fix is documented in a corner of the EAS caching reference: bump the cache.key field in eas.json, or pass --clear-cache on the next build.
{
"build": {
"preview": {
"distribution": "internal",
"cache": {
"key": "reanimated4-v2"
}
}
}
}
One eas build --platform ios --profile preview --clear-cache later, the binary launched cleanly. I now bump the cache key any time a native dependency changes, not just for Reanimated.
What to check before you ship
After migrating four production Expo apps to Reanimated 4 over the past month, here is my pre-ship checklist. None of these are in the official upgrade guide and all of them have bitten me at least once.
- Search for the old import. Run
grep -rn "from 'react-native-reanimated'" src/ | grep -E "runOn(UI|JS)". Any matches need updating.
- Verify the babel plugin is last.
react-native-worklets/plugin must be the final entry. NativeWind, Expo Router, and Sentry plugins all need to come before it.
- Test on a physical Android device. The Reanimated 4 frame scheduler behaves differently on low-end Android — animations that ran at 60fps in Reanimated 3 may now stutter on initial mount because of an extra layout pass. The official migration guide mentions the
experimentalLayoutTransitions flag as a workaround.
- Bump your EAS build cache key. Worth repeating. The Hermes precompile cache is the most common cause of "works locally, crashes on TestFlight" reports for this upgrade.
- Update Reanimated mocks in Jest. The package now exports a separate mock from
react-native-worklets/mock. Add it to your jest.setup.js alongside the existing Reanimated mock, otherwise tests that import shared values will throw.
If you are also upgrading Gesture Handler at the same time, our Expo SDK 53 gesture handler notes covers the related changes to Gesture.Native() that landed in 2.20.
FAQ
Do I need to install react-native-worklets if I am still on Reanimated 3?
No. Reanimated 3.x ships its own internal worklets runtime and does not read the standalone package. Only install react-native-worklets when you upgrade to Reanimated 4.0.0 or later. Installing it alongside Reanimated 3 will produce a "duplicate worklets runtime" warning at startup.
Can I use Reanimated 4 with Expo SDK 52?
Officially no — Expo's compatibility table pins SDK 52 to Reanimated 3.16. In practice the install works because there are no breaking native module changes, but you will lose Expo's testing guarantee and the EAS prebuild templates will not include the worklets autolinking entries. I would wait for SDK 53 unless you have a specific reason to upgrade Reanimated early.
Why does my IDE underline the "worklet" string as an error?
The TypeScript types for the 'worklet' directive moved to react-native-worklets in version 0.6. If your tsconfig.json has "types" set to an allowlist, add "react-native-worklets" to it. Otherwise TypeScript should pick it up automatically from node_modules/@types-style resolution.
Does this affect React Native Skia?
Yes, but only if you use Skia's animation hooks like useSharedValueEffect. Skia 1.7+ depends on react-native-worklets directly rather than Reanimated, so a clean Reanimated 4 install will also satisfy Skia's peer dependency. If you upgrade Skia separately, install worklets first.
Closing thoughts
The worklets split is one of those refactors that is obviously correct architecturally but rough on the upgrade path. Once the dust settles you end up with a smaller Reanimated bundle, a worklets runtime that other libraries can share, and a slightly cleaner mental model — animation code in Reanimated, thread-crossing in Worklets. The migration takes about an hour on a medium-sized app if you do the install steps in the right order and remember to clear the EAS cache.
If you hit a crash I have not covered here, the Software Mansion team is responsive on GitHub Discussions and the error messages in Reanimated 4 are genuinely better than they were in 3.x — most of them now include the file and line of the offending worklet. Pin the versions, run on a real Android device before you ship, and you should be fine.