UI Thread
A phone draws a new frame 60 to 120 times a second. When a frame isn't ready in time, the old one stays on screen and the motion stutters. In React Native the risk has a specific shape: your React code runs on the JS thread, the screen is drawn on the UI thread, and anything that crosses from one to the other every frame waits whenever JS is busy.
Drag the card on each thread with the JS thread busy.
Drag the card fast, or press Play. The busy JS thread is simulated, so the page itself stays free.
On the JS thread, each move waits for a 40 to 60 ms render to finish, so the card trails the finger (the dashed ring) and jumps in steps. On the UI thread, the gesture writes the position where the frame is drawn, and the busy JS thread can't hold it back. The two strips are the two numbers React Native's Perf Monitor shows, UI FPS and JS FPS, because each thread drops frames on its own.
Compute anything that follows the finger on the thread that draws the frames, and keep every frame inside the budget of a 120 Hz screen.
The budget
| Refresh rate | Frame budget | |---|---| | 60 Hz | 16.7 ms | | 90 Hz | 11.1 ms | | 120 Hz | 8.3 ms |
That budget covers the whole frame, the system's work included. Android's docs round it to 16, 11 and 8 ms and note that missing it by even 1 ms drops the frame. ProMotion iPhones switch between 10 and 120 Hz and can be capped by Low Power Mode or heat, so don't assume any one rate. A frame that is fine at 60 Hz can be late at 120.
A 12 ms frame is on time at 60 Hz and late at 90 and 120 Hz.
If you treat 16 ms as the budget, a 120 Hz phone shows every frame between 8.3
and 16.7 ms as a hitch. On iPhone, UIKit, SwiftUI and Core Animation run at
ProMotion rates on their own. Custom CADisplayLink or CAAnimation
frame-rate hints above 60 Hz need CADisableMinimumFrameDurationOnPhone set to
true in Info.plist (Apple).
React Native sets it by default since 0.82.
How much jank is too much
Both platforms publish numbers:
- Apple's hitch time ratio is milliseconds of late frames per second of motion. Under 5 ms/s is good, from 5 to 10 people start to notice, and at 10 or more Apple says to act now.
- Apple wants main-thread work under 100 ms so taps feel instant. Instruments reports 250 ms as a micro hang and 500 ms as a hang.
- Android vitals flags slow rendering when more than 50% of a session's frames miss the deadline, and frozen frames (over 700 ms) when they pass 0.1% of frames.
Judge all of it in release builds on a mid-range phone. React Native's dev mode runs JS much slower, and Flutter's debug mode is JIT-compiled, so measure every stack on a release or profile build.
Per stack
React Native
Drive gestures with Gesture Handler and Reanimated shared
values. Gesture callbacks and useAnimatedStyle run as worklets on the UI
thread. Call back into React with scheduleOnRN, which replaces the
deprecated runOnJS, and only when something is decided, like a dismiss.
Never setState from a gesture or onScroll on every frame, and don't read
.value during render, which blocks JS while the threads sync. Core
Animated with useNativeDriver: true also runs on the UI thread, but only
for transform and opacity. Layout props on the native driver are still
experimental, so animate transforms. Reanimated 4's default withSpring no
longer bounces (stiffness 900, damping 120, mass 4); see Springs
and pass the release velocity as in Momentum. For feeds, see
Native Lists.
Compose
Read fast-changing state in the layout or draw phase:
Modifier.offset { } and graphicsLayer { } instead of offset(x = ...) or
alpha(...), which recompose every frame. Give lazy list items a stable
key. A Baseline Profile makes code run about 30% faster from the first
launch.
SwiftUI
The main thread is the UI thread. Keep body cheap, and move
decoding, parsing and image work off the main actor.
Flutter
Your Dart code runs on the UI thread, which builds a layer tree and hands it
to the raster thread to draw. Keep build cheap: const widgets, and
AnimatedBuilder with the static subtree passed as child. Move heavy work
to an isolate with Isolate.run. Profile in profile mode, since debug builds
run JIT-compiled code with extra checks. New projects already set the
ProMotion key; older ones are capped at 60 Hz on iPhone until you add it.
Usage
@MainActor @Observable
final class Gallery {
var thumbnails: [Thumbnail] = []
func load(_ photos: [Photo]) async {
// The decode runs off the main actor; only the assignment hops back.
thumbnails = await Self.decode(photos)
}
@concurrent // Swift 6.2: always runs on the background executor
static func decode(_ photos: [Photo]) async -> [Thumbnail] {
photos.map { Thumbnail(decoding: $0.data) }
}
}
struct GalleryView: View {
@State private var gallery = Gallery()
let photos: [Photo]
var body: some View {
ThumbnailGrid(gallery.thumbnails) // body stays cheap
.task(id: photos) { await gallery.load(photos) }
}
}@Composable
fun DraggableCard() {
var offset by remember { mutableStateOf(Offset.Zero) }
Box(
Modifier
// Lambda version: read in layout, so dragging skips recomposition
.offset { IntOffset(offset.x.roundToInt(), offset.y.roundToInt()) }
.pointerInput(Unit) {
detectDragGestures { change, drag ->
change.consume()
offset += drag
}
}
.size(120.dp)
)
}
// A header that fades with scroll: read it in the draw phase.
Header(Modifier.graphicsLayer {
alpha = 1f - (listState.firstVisibleItemScrollOffset / 300f).coerceIn(0f, 1f)
})import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import Animated, { useAnimatedStyle, useSharedValue, withSpring } from 'react-native-reanimated';
import { scheduleOnRN } from 'react-native-worklets';
function DraggableCard({ onDrop }: { onDrop: (x: number, y: number) => void }) {
const x = useSharedValue(0);
const y = useSharedValue(0);
const pan = Gesture.Pan()
.onChange((e) => { // a worklet, on the UI thread
x.value += e.changeX;
y.value += e.changeY;
})
.onEnd((e) => {
x.value = withSpring(0, { velocity: e.velocityX });
y.value = withSpring(0, { velocity: e.velocityY });
scheduleOnRN(onDrop, e.absoluteX, e.absoluteY); // tell React once
});
const style = useAnimatedStyle(() => ({
transform: [{ translateX: x.value }, { translateY: y.value }],
}));
return (
<GestureDetector gesture={pan}>
<Animated.View style={[{ width: 120, height: 160 }, style]} />
</GestureDetector>
);
}class SpinningBadge extends StatelessWidget {
const SpinningBadge({super.key, required this.turns});
final Animation<double> turns;
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: turns,
// Built once, not on every frame.
child: const Badge(label: Text('New')),
builder: (context, child) =>
Transform.rotate(angle: turns.value * 2 * pi, child: child),
);
}
}
// Heavy work goes to another isolate, off the UI thread.
final thumbnails = await Isolate.run(() => decodeAll(photoBytes));With Reduce Motion on, keep the drag itself one-to-one with the finger; Apple
lists tracking gestures directly as a way to reduce motion. Reanimated
animations follow the system setting by default (ReduceMotion.System), so
withSpring and withTiming jump to their end, and the card lands without
the settle.
Resources
Eliminate animation hitches with XCTest - Apple's WWDC20 session that defines the hitch time ratio and its 5 and 10 ms/s thresholds.
Analyze hangs with Instruments - Why main-thread work should stay under 100 ms, and where micro hangs and hangs start.
Slow rendering - Android's frame budgets at 60, 90 and 120 fps, and how slow and frozen frames are counted.
Reanimated performance guide - Shared values on the UI thread, non-layout props, and how many views to animate at once.
Compose performance best practices - Deferring state reads with lambda modifiers, lazy list keys, and derivedStateOf.