React Native, the native driver, and the jank you can finally feel
When useNativeDriver actually buys you something, when it lies to you, and how to find the dropped frame that's making your scanner screen feel slow.
We have a barcode scanner screen in a warehouse app that ships about forty thousand scans per shift. The product team's feedback was always the same: "feels fine, but the modal that pops up after a scan is kinda slow." For two months I dismissed this as Android animation being Android animation.
Then I sat down with a warehouse worker and watched her scan 200 packages in a row. By the end I was the one calling it slow. The jank was real. It was also the kind of jank that doesn't show up in your dev cycle, because you never scan 200 things in a row at your desk.
Here's what I learned about useNativeDriver, the bridge, and what actually moves a frame.
What useNativeDriver does, briefly
Animation in React Native traditionally runs through the JS bridge. Every frame, JS computes the new value, ships it across the bridge to the native side, and the native side updates the view. That's a round-trip per frame, at 60 FPS. The bridge is mostly fine — until it's not.
useNativeDriver: true flips this. JS hands the animation declaration to the native side once, and the native side runs the interpolation entirely on the UI thread. JS can be busy, blocked, or on fire, and the animation still hits 60 FPS.
import { Animated } from "react-native";
const opacity = useRef(new Animated.Value(0)).current;
useEffect(() => {
Animated.timing(opacity, {
toValue: 1,
duration: 200,
useNativeDriver: true, // <-- the flag that matters
}).start();
}, []);The catch: the native driver only supports a subset of properties. Transform, opacity, scroll position — yes. Width, height, background color, anything that triggers layout — no. The moment you try to animate width with useNativeDriver: true, you get a runtime warning and the animation falls back to the JS driver, silently.
What I shipped (wrongly)
The scan-result modal slid up from the bottom and faded in. The original code:
Animated.parallel([
Animated.timing(translateY, {
toValue: 0,
duration: 250,
useNativeDriver: true,
}),
Animated.timing(modalHeight, {
toValue: 320,
duration: 250,
useNativeDriver: false, // can't native-drive height
}),
]).start();This looks fine. It animates. The translate runs on UI thread, the height runs on JS thread, they finish at the same time. What's the problem?
The problem is that during those 250ms, every scan callback, every Redux dispatch, every barcode-handler tick fights for the same JS thread that's busy interpolating the height. The screen drops frames. The next scan handler runs late. The user sees lag after the modal animation, not during it.
This is the jank that doesn't show up in Flipper unless you scan 200 things in a row.
What actually fixed it
Two things, in order:
1. Stop animating layout
The modal didn't need to grow. It needed to appear at full size, then slide up. So I replaced height animation with a transform from below the screen:
const translateY = useRef(new Animated.Value(SCREEN_HEIGHT)).current;
const opacity = useRef(new Animated.Value(0)).current;
useEffect(() => {
Animated.parallel([
Animated.timing(translateY, {
toValue: SCREEN_HEIGHT - 320,
duration: 220,
useNativeDriver: true,
}),
Animated.timing(opacity, {
toValue: 1,
duration: 220,
useNativeDriver: true,
}),
]).start();
}, []);Same visual result. Zero JS-thread work during the animation. The scan handler runs on time. The next barcode lands without lag.
2. Verify the driver is actually running native
The footgun is that React Native won't loudly tell you when useNativeDriver: true silently falls back. It logs a warning to the dev console and then proceeds with the JS driver. In production builds, the warning is gone.
I added a small dev-only assertion that fires on mount and trips a test if the animation lies:
function assertNativeDriverWorks(value: Animated.Value, name: string) {
if (__DEV__) {
// Internal API — accept that it might break across RN versions.
const isNative = (value as unknown as {
_animation: { _useNativeDriver: boolean } | null;
})._animation?._useNativeDriver;
if (isNative === false) {
// eslint-disable-next-line no-console
console.error(
`[anim] ${name} expected native driver, fell back to JS`,
);
}
}
}Yes, it touches private fields. Yes, it might break across RN upgrades. That's why it's wrapped in __DEV__ and we treat the assertion as a smoke test, not a runtime check.
The thing that surprised me
useNativeDriver: true is not always faster than the JS driver. For very short animations on a quiet screen, the JS driver is fine and the bridging overhead of handing the animation to native can show up in cold-start metrics. The native driver buys you a stable 60 FPS under contention. If your screen has no other work, both drivers look the same.
The win is exactly when the JS thread is busy. Which, in our case, is during a barcode-scan storm.
Trade-offs
You can't always native-drive. If your design uses height, width, padding, anything that triggers layout — you have a choice:
- Redesign the animation to use transform + opacity (almost always possible)
- Accept the JS-driver cost (fine if your screen is quiet)
- Move to Reanimated 3 with shared values and worklets, which sidesteps the whole subset problem
We use Reanimated for anything with gestures. We use the built-in Animated with useNativeDriver: true for everything else, because it's three lines of code and works.
You don't need any of this until you can feel a screen go slow under load. The day you can, the fix is the simplest thing in this post: stop animating layout.
The summary
The native driver isn't a magic flag. It's a contract that says: "I promise this animation only touches transform and opacity, so the UI thread can run it without help." Honour the contract, and the screen stays smooth no matter how busy JS gets. Break it silently, and you ship a scanner screen that feels fine for ten scans and slow for two hundred.
The bridge isn't slow. The bridge is busy with whatever else you're doing. Move the work off the bridge.
// while you're here
- 9 min read
Agentic AI is mostly while(true) with vibes
Production lessons from running autonomous agents in long-running loops, fallback patterns that actually work, and the day your agent decides to retry 47 times.
agentic-aiproductionengineering-lessons - 11 min read
Hybrid search with Qdrant: what nobody tells you about BM25 + dense + image
What you actually wire up when you blend keyword, dense vector and image embeddings into one ranker — named vectors, fusion, drift, and the day your Turkish search broke on apostrophes.
hybrid-searchqdrantembeddingsproduction