Keeping the UI at 60fps

The UI thread against the JS thread, how to tell which one dropped the frame, and how to move work out of the way.

Syntaxconst id = requestIdleCallback(deadline => { /* work */ }, { timeout: 500 })

A React Native app runs your code on the JS thread and draws on the main thread, often called the UI thread. At 60fps each frame has about 16.67ms. If the JS thread is busy past that, the frames it owed are dropped: touches respond late and JavaScript-driven animations stutter. Work the main thread owns keeps going meanwhile, which is why a native stack transition and a ScrollView still scroll smoothly while your JavaScript is stuck.

Find out which thread stalled

Show Perf Monitor in the dev menu reports the JS frame rate and the UI frame rate separately. A low JS number means your code is too slow. A low UI number means the native side is doing too much: oversized images, shadows on many views, or a very deep view tree. Check the numbers in a release build, because development mode slows the JS thread for reasons that will not ship.

Moving work off the JS thread

Reanimated worklets run on the UI thread and survive a busy JS thread. Core Animated animations do the same with useNativeDriver: true, which cannot animate layout properties such as width or height. Long lists belong in FlatList, which keeps a window of rows mounted; a ScrollView mounts every child and blocks the JS thread while it does.

For your own long tasks, split them up and schedule the pieces with requestIdleCallback(callback, { timeout }), cancelling with cancelIdleCallback(id). InteractionManager.runAfterInteractions did this job for years; it is deprecated, the documentation now points at requestIdleCallback, and its API page is gone from the 0.87 reference. Expect to still see it in older code.

Example

Example · tsx
import { useEffect, useState } from "react";
import { FlatList, StyleSheet, Text, View } from "react-native";
import { buildIndex, type IndexEntry } from "../lib/searchIndex";

export default function SearchScreen() {
  const [index, setIndex] = useState<IndexEntry[]>([]);

  // Hooks run unconditionally, in the same order, every render.
  useEffect(() => {
    // Building the index takes about 120ms, which is seven frames. Running it
    // when the JS thread is idle lets the screen transition finish smoothly.
    // timeout guarantees it runs even if the thread never goes idle.
    const handle = requestIdleCallback(() => setIndex(buildIndex()), {
      timeout: 1000,
    });

    // Concise arrow body; the block form () => { cancelIdleCallback(handle); }
    // is the same thing.
    return () => cancelIdleCallback(handle);
  }, []);

  return (
    <View style={styles.container}>
      <Text style={styles.heading}>
        {index.length === 0 ? "Preparing search" : `${index.length} entries indexed`}
      </Text>
      {/* FlatList, not ScrollView: this list has no fixed upper length. */}
      <FlatList
        data={index}
        keyExtractor={(entry) => entry.id}
        renderItem={({ item }) => <Text style={styles.row}>{item.title}</Text>}
      />
    </View>
  );
}

const styles = StyleSheet.create({
  // flexDirection defaults to column here, not row as in CSS.
  container: { flex: 1 },
  heading: { padding: 16, fontSize: 16, fontWeight: "600" },
  row: { paddingHorizontal: 16, paddingVertical: 10 },
});

When to use it

  • A modal animates in while a large API response is being parsed, and the animation stutters.
  • Scrolling a feed drops frames, and you need to know whether it is your code or the native views.
  • A tab switch feels sticky because the new screen does setup work during the transition.
  • An import or migration step freezes the app for a second.

More examples

An animation that ignores a busy JS thread

The card fades in and rises slightly over 300ms. Because the style is computed in a worklet on the UI thread, the fade stays smooth even if the JS thread is parsing a large response at the same time. This is the usual answer when an entrance animation looks fine in isolation and janky in the real screen.

Example · tsx
import { useEffect } from "react";
import { StyleSheet, Text } from "react-native";
import Animated, {
  useAnimatedStyle,
  useSharedValue,
  withTiming,
} from "react-native-reanimated";

type FadeInCardProps = { title: string };

export default function FadeInCard({ title }: FadeInCardProps) {
  const progress = useSharedValue(0);

  useEffect(() => {
    progress.value = withTiming(1, { duration: 300 });
  }, [progress]);

  // This function is a worklet: it is evaluated on the UI thread, so the
  // animation keeps its frames even while the JS thread is blocked.
  const animatedStyle = useAnimatedStyle(() => ({
    opacity: progress.value,
    transform: [{ translateY: (1 - progress.value) * 12 }],
  }));

  return (
    <Animated.View style={[styles.card, animatedStyle]}>
      <Text style={styles.title}>{title}</Text>
    </Animated.View>
  );
}

const styles = StyleSheet.create({
  card: { margin: 16, padding: 16, borderRadius: 12, backgroundColor: "#f3f4f6" },
  title: { fontSize: 16, fontWeight: "600" },
});

The same idea with the core Animated API

A label that pulses between 30% and 100% opacity until the component unmounts. Reach for Animated when you do not want the Reanimated dependency. The constraint is real: with useNativeDriver: true you may animate opacity and transform, so a growing-width progress bar has to use Reanimated or a transform scale instead.

Example · tsx
import { useEffect, useRef } from "react";
import { Animated, StyleSheet, Text } from "react-native";

type PulseProps = { label: string };

export default function Pulse({ label }: PulseProps) {
  const opacity = useRef(new Animated.Value(0.3)).current;

  useEffect(() => {
    const animation = Animated.loop(
      Animated.sequence([
        // useNativeDriver sends the whole animation to the native side once.
        // It supports opacity and transform, NOT width, height or margin.
        Animated.timing(opacity, { toValue: 1, duration: 600, useNativeDriver: true }),
        Animated.timing(opacity, { toValue: 0.3, duration: 600, useNativeDriver: true }),
      ]),
    );
    animation.start();
    return () => animation.stop();
  }, [opacity]);

  return (
    <Animated.View style={[styles.dot, { opacity }]}>
      <Text style={styles.label}>{label}</Text>
    </Animated.View>
  );
}

const styles = StyleSheet.create({
  dot: { alignSelf: "center", marginTop: 24, padding: 8 },
  label: { color: "#6b7280" },
});

Splitting one long task into frame-sized pieces

Pure functions, so this one prints. A loop over 5,000 items is a single uninterruptible block on the JS thread; fifty blocks of a hundred items each let frames render between them. Use it for importing a file, decrypting a cache or building an index, and keep a cancel flag if the screen can unmount mid-run.

Example · tsx
export function chunk<T>(items: T[], size: number): T[][] {
  const batches: T[][] = [];
  for (let i = 0; i < items.length; i += size) {
    batches.push(items.slice(i, i + size));
  }
  return batches;
}

console.log(chunk([1, 2, 3, 4, 5, 6, 7], 3));
// Output: [ [ 1, 2, 3 ], [ 4, 5, 6 ], [ 7 ] ]

// Process one batch per idle period instead of all of them in one go.
export function processWhenIdle<T>(items: T[], handle: (batch: T[]) => void) {
  const batches = chunk(items, 50);
  let next = 0;

  function step() {
    if (next >= batches.length) return;
    handle(batches[next]);
    next += 1;
    requestIdleCallback(step, { timeout: 200 });
  }

  requestIdleCallback(step, { timeout: 200 });
}

Discussion

  • Be the first to comment on this lesson.