Why native apps never stutter (and your React Native list does)
Swipe an email in Apple Mail and it slides off the screen, then the rows below it glide up to fill the gap. It does this every single time. Scroll fast, archive five in a row, do it while the phone is busy loading something: still smooth.
Try the same thing in a typical React Native app and every so often it hitches. The row leaves, and then the list sits frozen for a beat before the rows below catch up. Same gesture, same idea, worse feel.
The surprising part is that this has almost nothing to do with the animation code. It comes down to one question: which worker is holding the paintbrush.
A phone has one main worker
Picture the part of your app that runs your code as a single worker at a desk. It does two jobs. It thinks (runs your logic, responds to taps, loads data) and it paints (decides what the screen should look like next). People call this the main thread, or on the UI side, the UI thread. One worker, both jobs.
The screen wants a fresh picture on a schedule. A frame is one of those pictures. A normal phone shows 60 a second; a nice one shows 120. At 120, that is a new picture every 8 milliseconds, which is about as long as a housefly's wingbeat. Miss that deadline and the eye sees a stutter.
So the whole game is: hand the screen a finished picture every 8ms, without fail. If your one worker is busy thinking when a picture is due, nobody is painting, and you drop a frame.
That sounds like a problem native apps should have too. They don't. Here is the trick.
Apple hands the animation to a second worker
When an iPhone animates something, your app does not paint each frame itself. It describes the whole animation once (start here, end there, take this long, use this curve) and hands that description across to a different program running in a separate process. That program, part of a system service historically called backboardd, sits closer to the screen, runs at high priority, and paints every in-between frame on its own clock.
The consequence is the whole story:
Once your app has handed over the animation, your worker can be completely swamped and the animation still runs. The other process already has everything it needs. It does not stop to ask your app anything. So Apple Mail's rows keep gliding even while the main thread is off parsing a giant email, because the gliding is not happening on the main thread at all.
For the curious: Core Animation keeps three copies of the view hierarchy. The model tree is what you set (the final frame). The presentation tree is what is actually on screen mid-animation (you can read it back with layer.presentation()). The render tree is the private copy the render server is drawing from, in its own process. Your app talks to the first two; the third one is why a blocked app keeps animating. Frames are locked to the display's refresh with VSync, 16.67ms at 60Hz or 8.33ms at 120Hz.
In sequence, one animation looks like this. Notice how few times the app has to be involved:
The app speaks once. The render server does the other hundred frames alone. That is the architecture that makes native motion feel unbreakable.
Where React Native starts one step behind
Now the same swipe in React Native. A long list is not a real stack of a thousand rows; that would be too heavy. It is a small window of maybe twenty rows that gets recycled as you scroll, and the math for where each row goes is computed in JavaScript.
That last part is the catch. When you remove a row, the positions of the rows below it have to be figured out in JavaScript before anything can move. And JavaScript here is that same single worker doing everything else: running your components, applying data updates, talking to the cache. If it is busy when the glide needs its next position, the glide waits. That wait is the hitch.
So it is not that React Native's animation code is worse. It is that the thing the animation depends on (the new layout) lives on a thread that is often busy, and there is no separate render-server process quietly finishing the job.
This is exactly why Reanimated exists. It lets you write little animation functions, called worklets, that run on the UI thread instead of the JavaScript thread:
const style = useAnimatedStyle(() => {
'worklet';
return { transform: [{ translateX: x.value }] };
});That 'worklet' marker is the important bit. It says "run this on the UI thread." A finger-tracking swipe written this way stays smooth even when the JavaScript thread is jammed, for the same reason Apple's render server does: the motion is no longer stuck behind the busy worker.
Reanimated gets you close, but not all the way to parity. Its worklets run on the UI thread, which is still one thread inside your app. Apple's render server is a separate system process compositing every app on screen, so a native animation survives even a fully wedged app. Reanimated survives a busy JavaScript thread, which covers most real jank, but a truly blocked UI thread will still stall it. Same idea, one level short.
The three ways out
If you accept that motion janks when it has to wait on a busy thread, there are only three real fixes, and every smooth React Native list uses some mix of them:
- Move the animation off the busy thread. Worklets on the UI thread, so the motion does not wait on JavaScript at all.
- Make the thread's work cheaper. If the layout math or the data update is fast, it does not matter as much that it shares a thread with the animation.
- Do the work less often, and later. Push anything that does not have to happen right now to a quieter moment, out of the animation's path.
Native gets the first one for free from the operating system. In React Native you have to build all three yourself, on purpose.
That is the setup. In the next post I take one real swipe-to-archive in a React Native email app, find the half-second freeze hiding between two swipes, and use all three levers to make it glide like Mail.