The Yoke Reels feed was the canary for performance on the whole app. TikTok-style vertical paging, video playback, double-tap-to-like with a heart burst — on a flagship iPhone it ran flawlessly. On a 2-year-old Android with a Snapdragon 665, scrolling stuttered, the heart animation skipped frames, and battery temp climbed within 10 minutes of use. The fix wasn't more native code — it was moving every animation off the JS thread using Reanimated v4 worklets.
Why JS-thread animation breaks on low-end devices
By default, an Animated.Value or setState-driven transform runs on the JS thread, then crosses the bridge to the UI thread every frame. If JS is busy doing anything else (a Redux dispatch, a network response, image decoding), frames are dropped. On a Snapdragon 665 the JS thread is already saturated by the React reconciler. Worklets bypass this entirely by compiling animation logic to run on the UI thread directly — no bridge crossing per frame, no JS dependence.
Rebuilding the scroll-driven crossfade as a worklet
The original feed used Animated.event with useNativeDriver: true for the scroll position, but the crossfade between adjacent reels (opacity ramp at the edges) was computed in React on the JS thread. Replacing it with useAnimatedScrollHandler + useAnimatedStyle moved the entire computation to the UI thread. We saw a measurable 12–15ms reduction in scroll-event handling on the JS thread on the Snapdragon device.
import Animated, {
useAnimatedScrollHandler,
useAnimatedStyle,
useSharedValue,
interpolate,
Extrapolation,
} from 'react-native-reanimated';
const { height: H } = Dimensions.get('window');
function ReelsFeed({ reels }) {
const scrollY = useSharedValue(0);
const onScroll = useAnimatedScrollHandler((e) => {
'worklet';
scrollY.value = e.contentOffset.y;
});
return (
<Animated.FlatList
data={reels}
onScroll={onScroll}
scrollEventThrottle={16}
pagingEnabled
renderItem={({ item, index }) => (
<ReelCard reel={item} index={index} scrollY={scrollY} />
)}
/>
);
}
function ReelCard({ reel, index, scrollY }) {
const animatedStyle = useAnimatedStyle(() => {
'worklet';
const start = index * H;
const opacity = interpolate(
scrollY.value,
[start - H * 0.6, start, start + H * 0.6],
[0, 1, 0],
Extrapolation.CLAMP,
);
return { opacity };
});
return (
<Animated.View style={[styles.card, animatedStyle]}>
{/* ... */}
</Animated.View>
);
}Double-tap-to-like — gesture-driven worklets
The heart burst animation used to fire from a Redux dispatch via componentDidUpdate. Result: a 60–100ms delay between tap and visual on the slow device. We moved the entire double-tap detection and burst into a Gesture.Tap().numberOfTaps(2) handler with the animation in a worklet — the spring fires instantly, and the Redux dispatch happens via runOnJS afterward. The user sees instant feedback; the server-side mutation runs in the background.
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
withTiming,
runOnJS,
} from 'react-native-reanimated';
function HeartBurst({ reelId, onLike }) {
const scale = useSharedValue(0);
const opacity = useSharedValue(0);
const doubleTap = Gesture.Tap()
.numberOfTaps(2)
.onEnd(() => {
'worklet';
scale.value = withSpring(1, { damping: 9, stiffness: 220 });
opacity.value = withTiming(1, { duration: 120 }, () => {
opacity.value = withTiming(0, { duration: 280 });
scale.value = withTiming(0, { duration: 280 });
});
runOnJS(onLike)(reelId); // network call off the UI thread
});
const heartStyle = useAnimatedStyle(() => ({
transform: [{ scale: scale.value }],
opacity: opacity.value,
}));
return (
<GestureDetector gesture={doubleTap}>
<Animated.View style={styles.hitbox}>
<Animated.View style={[styles.heart, heartStyle]} />
{/* video / content underneath */}
</Animated.View>
</GestureDetector>
);
}Lifecycle-aware playback — the memory win
Each reel mounts a react-native-video instance. With windowSize: 5 on the FlatList, that's up to 5 video players in memory at any time — most of them out of view but still allocated. On low-end Android this peaked our RAM at 450MB and triggered OOM on Android 8 devices. The fix: use useIsFocused logic on each card and a custom isActiveIndex prop. Only the focused card holds the player; adjacent cards render a static thumbnail. Memory dropped ~30%, scroll became measurably smoother because GC pressure fell off.
The Reanimated v4 New Architecture caveat
Reanimated v4 requires the New Architecture (Fabric + JSI). Some older libraries with class component patterns or imperative refs won't work cleanly with worklets. We had to drop two libraries and reimplement them: a custom carousel that used findNodeHandle (incompatible with Fabric) and a parallax library that mutated refs from outside Animated. If you're on RN < 0.74, evaluate Reanimated v3 first — v4's wins aren't worth a forced architecture migration if you're not already planning one.
Rule of thumb on RN animation: if the animation depends on user input (scroll, gesture, drag), it should be a worklet. If it's a fire-and-forget transition (modal open, route change), Animated with useNativeDriver: true is enough. The cost of authoring worklets is real — strict syntax, harder debugging, runOnJS for any JS-side effect — but on low-end Android it's the only path to consistent 60fps.