React Native Animations with Reanimated in Production: What Nobody Tells You

Three months into shipping animations in a music practice app, I started getting bug reports that made no sense. A progress bar stuck at 0. A tempo visualizer frozen mid-beat. No crash logs. No JavaScript errors. The animation just... stopped responding.
The culprit wasn't my logic. It was a single object literal I'd passed into a worklet.
That's Reanimated in production: the bugs don't announce themselves. They accumulate silently until a real user on a mid-range Android device sends you a video of a broken UI, and you spend a day staring at code that looks completely correct.
Here's what I've learned shipping animations as core UX, not decoration, with Reanimated 3 on Expo.
The object copy trap (the most silent bug in the library)
If you pass a plain JavaScript object into a worklet (not a useSharedValue, just a regular object), Reanimated copies it once to the UI thread and treats it as immutable. Any update you make to that object on the JS side afterward is silently ignored.
const config = { bpm: 120, signature: 4 };
const animateBar = useAnimatedStyle(() => {
// config.bpm here is FROZEN at 120, forever
return { width: withTiming(config.bpm * 2) };
});
The app doesn't crash. The animation just uses stale data. In development, Reanimated will warn you about this, but only in dev, and only if you're paying attention to the console. In production, nothing.
The fix is obvious once you know it: everything that changes needs to be a useSharedValue. The trap is insidious because the code looks like it should work, and it does work on first render.
The Software Mansion docs cover this in their worklets troubleshooting guide, but it's buried after the getting-started happy path. I found it by accident while debugging, not by reading ahead.
Tree shaking kills worklets on Android prod builds
This one hit us hard. Animations worked perfectly in development, worked in Expo Go, worked in TestFlight. Android production release: instant crash on any screen with an animation.
The bug is documented in GitHub issue #8752 (open as of this writing). If your Metro or babel config has aggressive tree shaking enabled, the bundler can eliminate the initialization code for the native Worklets runtime. Reanimated expects the native module to be initialized before any worklet runs. If tree shaking strips that bootstrap call, you get a crash with no meaningful error message.
The workaround: explicitly tell your bundler to treat Reanimated's entry points as side-effect-full. In your babel.config.js:
plugins: [
'react-native-reanimated/plugin', // this must be LAST
]
Verify your Metro config isn't pruning the native init. This is Android-only because iOS and Hermes handle the module loading differently. It's one of those bugs that will convince you Android hates you personally.
The New Architecture is not a free animation upgrade
Everyone is pushing the New Architecture. Expo SDK 53+ enables it by default. The messaging is: faster, more modern, better interop. For most things, that's true.
For Reanimated specifically, it's complicated.
Here's the part that trips people up: Reanimated has been using JSI, the JavaScript Interface that bypasses the bridge, since 2020. That's the reason it can drive 60fps animations entirely on the UI thread. The New Architecture didn't give Reanimated its performance model. It already had one.
What the New Architecture does change is how Fabric (the new renderer) interacts with the animated styles Reanimated produces. And Software Mansion's own performance docs explicitly note regressions after enabling it on Expo SDK 53+.
My approach: before migrating any app with complex animations, bench your critical animation paths. Run them on a mid-range Android device (not a Pixel 8 Pro, something from 2021), measure frame rates before and after. Don't assume the upgrade is a win. It might be neutral, it might regress. Verify.
Expo Go is toxic for non-trivial animation development
Use a development build. Always. From the first component that uses useSharedValue.
Expo Go embeds a fixed version of Reanimated tied to the SDK version. If your project uses a different version (which it will, because Reanimated releases faster than Expo SDK cycles), you're developing against the wrong native layer. Bugs you can't reproduce in Expo Go will hit production. Bugs you can reproduce in Expo Go might not exist in your actual build.
EAS Build for development clients exists precisely for this. It takes longer to set up initially but saves days of misattributed debugging. The Expo team knows this. They just can't force everyone to adopt it.
The missing 'worklet' directive and the silent JS thread fallback
This is subtle and I've been burned by it. If a function inside a worklet calls another function that isn't marked with 'worklet', Reanimated doesn't crash. It falls back to executing that function on the JS thread instead of the UI thread.
Your animation still runs. But now parts of it are going through the bridge (or the message queue in New Arch), and you lose the 60fps guarantee for those frames. The degradation is gradual. You notice it on slower devices as jank that appears under load but not in dev.
function calculatePosition(progress) {
// Missing 'worklet' directive
return progress * screenWidth;
}
const animatedStyle = useAnimatedStyle(() => {
// This call is silently demoted to JS thread
const pos = calculatePosition(progress.value);
return { transform: [{ translateX: pos }] };
});
Add 'worklet'; as the first line of any function called from within animated styles, gesture handlers, or other worklets. The Reanimated ESLint plugin catches most of these at write time. Enable it.
SharedValue lifecycle in long-running components
In an app where animations run continuously (a metronome, a playback visualizer, a real-time waveform), SharedValues created in hooks need careful lifecycle management.
SharedValues survive re-renders, which is what you want for smooth animation. But if the component that created them unmounts and remounts through navigation or conditional rendering, you can end up with multiple SharedValue instances driving the same animated element, or with useAnimatedReaction callbacks still firing on unmounted components.
The pattern that works: create SharedValues in stable hooks (useMemo with [] deps for truly static values, or at module level for global animation state). Cancel animations explicitly in cleanup functions. If you're using runOnJS callbacks that update React state, guard against unmounted component updates.
None of this is unique to Reanimated. It's React lifecycle management applied to animation state. The failure modes are nastier because they manifest as visual glitches that are hard to reproduce.
What production Reanimated actually requires
The library is excellent. Software Mansion ships fast and the API surface is well-designed. But Reanimated operates at the intersection of three runtimes (the JS thread, the UI thread, and the native layer), and bugs at those boundaries are subtle by nature.
The discipline is: treat every worklet boundary as a potential failure point, pin your native dependencies hard, never test with Expo Go, and bench before migrating architectures. Animation bugs are the worst kind. They're visual, user-visible, and often impossible to reproduce in dev.
If you're building something complex in React Native and want a second pair of eyes on the architecture, check out my portfolio or reach out directly.