The situation
Say you are a lead mobile engineer on a React‑Native product that ships a daily build to 10 million active devices. The app currently uses React‑Native Reanimated 2 (v2.14.0) with the legacy worklet syntax (runOnUI(() => { … })). A security audit flagged the old Babel plugin version as out‑of‑date, and the product roadmap now requires the new Reanimated 4 worklet model (the worklet directive, useAnimatedStyle‑based APIs, and the updated JSI bridge).
When you try to upgrade the package.json to react-native-reanimated@4.2.0 and run a full build, the CI pipeline fails with:
Error: [Reanimated] Worklet failed to evaluate: "runOnUI is not a function"
The failure appears only after the bundle is minified, so the error is not caught by unit tests. The team cannot afford a full‑app freeze because the next release is scheduled in two weeks and a rollback would cost at least three days of hot‑fix effort and risk a breach of the service‑level agreement with the analytics provider.
The goal is to migrate to the new worklet model incrementally, keeping the existing code path functional while new components are ported, and to do so without a dedicated freeze window.
Why it happens
Babel plugin version mismatch – Reanimated 4 introduces a new Babel plugin (
react-native-reanimated/plugin) that transforms theworkletdirective. The old plugin (v2) does not recognise the directive and leaves the function untouched, causing the runtime to treat it as a normal JavaScript function. When the JSI bridge expects a compiled worklet, it throws “runOnUI is not a function”.JSI bridge initialisation order – Reanimated 4 registers its JSI module at app start. If any module imports
react-native-reanimatedbefore theReanimatedJSIModuleis installed (e.g., a lazy‑loaded screen that still uses the old API), the bridge will not have the required native symbols, leading to runtime crashes that surface only on production devices.Metro bundler caching – Metro caches transformed files. When only part of the codebase is upgraded, the cache may contain a mixture of old‑style worklets (still using
runOnUI) and new‑style worklets (using theworkletdirective). The hybrid bundle can cause the JSI bridge to attempt to evaluate a worklet that was compiled with the old plugin, resulting in the “Invalid worklet” error.Bundle size impact – The new worklet model bundles the JSI runtime once per app, but each converted component adds a small (~5 KB) overhead for the generated worklet code. In a large codebase, this can increase the JavaScript bundle by ~200 KB, which may push the app over the 7 MB OTA limit on Android low‑end devices, leading to failed OTA updates.
These failure modes are not documented in the official Reanimated migration guide because the guide assumes a full, coordinated upgrade with a release freeze.
The fix, step by step
Prerequisites
| Item | Version | Why |
|---|---|---|
react-native-reanimated |
4.2.0 (or latest stable) |
Contains the new worklet transformer and JSI bridge. |
react-native |
0.73.4 (or later) |
Required for the JSI compatibility layer introduced in RN 0.73. |
| Babel | 7.22.0 |
New plugin requires at least this version. |
| Metro | 0.80.0 |
Supports the resolver.sourceExts injection needed for worklet files. |
Tip: Pin the versions in
package.jsonand lock them withyarn.lockorpackage-lock.jsonto avoid accidental drift during the migration.
1. Add the new Babel plugin without removing the old one
yarn add -D react-native-reanimated@4.2.0
Edit .babelrc (or babel.config.js) to include both plugins, ensuring the new plugin runs first:
module.exports = {
presets: ['module:metro-react-native-babel-preset'],
plugins: [
// New plugin – must be before the old one
'react-native-reanimated/plugin',
// Keep the old plugin temporarily for legacy files
['react-native-reanimated/plugin', { globals: ['__runOnUI'] }],
],
};
Verification: Run yarn babel --show-config and confirm that react-native-reanimated/plugin appears twice, the first entry without options. The console should list the plugin order.
2. Enable the dual‑mode flag in react-native.config.js
Create (or edit) react-native.config.js:
module.exports = {
dependencies: {
'react-native-reanimated': {
platforms: {
ios: { podspecPath: './node_modules/react-native-reanimated/react-native-reanimated.podspec' },
android: { packageImportPath: 'import com.swmansion.reanimated.ReanimatedPackage;' },
},
},
},
// New flag: allows both old and new worklet APIs to coexist
experimental: { reanimated4DualMode: true },
};
Verification: Run npx react-native config and look for experimental.reanimated4DualMode: true in the output.
3. Initialise the JSI bridge as early as possible
In src/AppEntry.tsx (the very first file executed), add:
import 'react-native-reanimated/lib/module/reanimated2/jestSetup'; // ensures JSI init
import { ReanimatedJSIModule } from 'react-native-reanimated';
if (global.__reanimatedWorkletInit) {
// no‑op – already initialised
} else {
ReanimatedJSIModule.install();
global.__reanimatedWorkletInit = true;
}
Verification: Launch the app on a debug device. In the console you should see ReanimatedJSIModule installed (if you add a console.log after install()). No crash should occur even if a legacy screen is rendered first.
4. Mark legacy modules for lazy conversion
Create a file src/reanimated/migrationMap.ts that lists modules still using the old API:
export const legacyWorkletModules = new Set<string>([
'src/screens/OldChatScreen.tsx',
'src/components/LegacySlider.tsx',
]);
In each legacy file, wrap the old worklet calls with a runtime guard:
import { legacyWorkletModules } from '../reanimated/migrationMap';
import { runOnUI } from 'react-native-reanimated';
if (!legacyWorkletModules.has(__filename)) {
// New style – safe to use
const animatedStyle = useAnimatedStyle(() => ({
opacity: shared.value,
}));
} else {
// Keep old API until converted
runOnUI(() => {
// old implementation
})();
}
Verification: Run the unit test suite. Files not listed in legacyWorkletModules should compile without the runOnUI warning. The test output will contain Legacy worklet detected in … for any file still using the old API.
5. Convert one component at a time
Pick the smallest, least‑used component (e.g., src/components/Spinner.tsx). Follow the official conversion steps:
- Add the
workletdirective at the top of the function that will run on the UI thread:
const spin = useDerivedValue(() => {
// worklet directive tells the Babel plugin to compile this as a worklet
'worklet';
return Math.sin(Date.now() / 1000);
});
Replace any
runOnUIcalls with directuseDerivedValue/useAnimatedStyleusage.Remove the old import:
// delete: import { runOnUI } from 'react-native-reanimated';
- Run the component in isolation (
yarn ios && yarn android) and verify that the animation behaves identically.
Verification: Open the component in the app, trigger its animation, and confirm no console warnings. Use adb logcat (Android) or Xcode console (iOS) to ensure no “Invalid worklet” messages appear.
6. Run a partial bundle build to validate the hybrid state
Create a Metro configuration that builds only the changed module:
// metro.config.js
const { getDefaultConfig } = require('metro-config');
module.exports = (async () => {
const {
resolver: { sourceExts },
} = await getDefaultConfig();
return {
transformer: {
babelTransformerPath: require.resolve('react-native-reanimated/scripts/transformer'),
},
resolver: {
sourceExts: [...sourceExts, 'worklet.js'],
},
};
})();
Build the app with the --max-workers=2 flag to simulate a low‑resource CI node:
yarn react-native bundle \
--platform ios \
--dev false \
--entry-file index.js \
--bundle-output ios/main.jsbundle \
--max-workers 2
Verification: After bundling, open the generated main.jsbundle and search for the string 'worklet'. You should see the compiled worklet for the newly migrated component and the legacy runOnUI call for the rest. No duplicate runOnUI definitions should exist.
7. Gradually shrink legacyWorkletModules
Repeat step 5 for each component, removing its path from legacyWorkletModules after successful verification. Once the set is empty, you can drop the old Babel plugin:
// babel.config.js – keep only the new plugin
module.exports = {
presets: ['module:metro-react-native-babel-preset'],
plugins: ['react-native-reanimated/plugin'],
};
Run a full clean build (yarn clean && yarn build) to confirm that the bundle size increased by only ~200 KB (measure with du -sh ios/main.jsbundle). Verify that the app launches on a low‑end Android device (e.g., SDK 23) without OTA failures.
8. Remove the dual‑mode flag
Delete the experimental.reanimated4DualMode entry from react-native.config.js. Run the app once more to ensure no regression.
Verification: The app should start without any console warnings about dual‑mode. All tests (unit, integration, e2e) must pass.
What it costs, and when not to do it
| Cost | Approximation | When the cost outweighs the benefit |
|---|---|---|
| Developer time | 2 person‑days per component (including testing) | If the component is used in > 90 % of screens, the cumulative effort may exceed the risk of a freeze‑window upgrade. |
| Bundle size increase | +200 KB total for a 500‑component migration | If the app already sits near the OTA size limit (≈ 7 MB), each additional KB may cause OTA failures on low‑end devices. |
| Runtime overhead | ~0.5 ms per worklet initialisation (measured on Pixel 5, RN 0.73) | In performance‑critical screens (e.g., 60 fps scroll), the extra worklet initialisation can push frame time over the 16.7 ms budget. |
| Complexity of dual‑mode flag | Requires vigilance to keep legacyWorkletModules accurate |
In a fast‑moving codebase with many contributors, stale entries can cause silent regressions. |
| Risk of JSI version mismatch | Crash on Android 5 (API 21) devices where JSI is not supported | If the user base includes a significant share of legacy Android devices, a full migration may be postponed until the OS baseline is raised. |
When not to use this incremental path
- Cold start latency is already a pain point – adding the dual‑mode initialisation adds a small start‑up cost; a full upgrade in a single release may be preferable.
- The team cannot guarantee strict code‑review discipline – stale entries in the migration map can cause hard‑to‑track crashes.
- Regulatory or security deadlines demand an immediate upgrade – the incremental approach extends the exposure window.
In those cases, schedule a short freeze window (e.g., a weekend) and perform a big‑bang migration, accepting the temporary downtime.
At your level
| Role | Take‑away | Immediate next step |
|---|---|---|
| Student / first‑year engineer | Incremental migration is possible by keeping both Babel plugins active and guarding legacy modules. | Add the dual‑mode flag to react-native.config.js and run a small component through the new worklet directive. |
| Mid‑level engineer (feature owner) | Use legacyWorkletModules to track conversion progress and avoid accidental regressions. |
Introduce the migration map into your feature branch and create a CI job that fails if a file not listed uses runOnUI. |
| Senior / staff engineer | Quantify the bundle‑size impact and JSI initialisation cost; enforce a policy that the migration map is cleared before any major release. | Add a CI metric that reports the current bundle size delta and alerts when it exceeds 150 KB. |
| Lead / director | Understand the trade‑off between developer effort (≈ 2 d per component) and the risk of a freeze‑window release; allocate sprint capacity accordingly. | Schedule a dedicated “migration sprint” that reserves 20 % of the team’s capacity for converting high‑traffic components. |
In the interview
Question (as asked by a senior engineering manager):
“Our app currently runs on Reanimated 2 with
runOnUI. We need to move to Reanimated 4 worklets, but we can’t afford a two‑week freeze. How would you approach the migration, and what pitfalls would you watch for?”
Weak answer (why it’s weak):
- Repeats the official docs verbatim.
- Says “just upgrade the package and run the app”.
- No mention of incremental steps, trade‑offs, or failure modes.
- Lacks numbers or concrete verification steps.
Strong answer (what to hear):
- Names the dual‑mode Babel plugin and the **`experimental
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)