Skip to content
/60fps Reels on Low-End Android with Reanimated v4 Worklets
Apr 2026·7 min read

60fps Reels on Low-End Android with Reanimated v4 Worklets

How we rebuilt the Yoke Reels feed to hold 60fps on a Snapdragon 665 — covering Reanimated v4 worklets, useAnimatedScrollHandler, runOnJS bridging, and the lifecycle-aware playback trick that cut RAM by ~30%.

React NativeReanimatedAnimationPerformance
Text-to-Speech unsupported
Text Size

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.

ReelsFeed — worklet-driven crossfade
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.

Heart-burst gesture with runOnJS
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.

Share X LinkedIn
NY
Neelesh Yadav
React Native Developer with 3 yr 6 mos years building production mobile apps. Writes about performance, architecture, and mobile engineering.
Follow ↗

Comments

Leave a comment

Comments are reviewed before they appear.

More Articles

Boosting React Native Performance: New Architectures Deep Dive
8 min read
Building Real-Time Chat with WebSocket in React Native
6 min read
AR Try-On: Three.js + MediaPipe in React Native
10 min read