React Native Performance: From Janky to 60fps
The profiling workflow and rendering fixes that took a laggy React Native app from dropped frames to smooth 60fps on both iOS and Android.
React Native performance complaints are almost always specific rather than general. The app is not uniformly slow; one list stutters, one screen takes too long to appear, one animation drops frames. Treating it as a general problem leads to broad refactors that change nothing. Treating it as three specific problems usually resolves it in a few days.
The starting discipline: profile on a low-end Android device. Not the simulator, which has desktop-class hardware and hides everything, and not a recent iPhone, which is fast enough to mask most issues. The device where the problem is visible is the device where you can measure the fix.
Know Which Kind of Slow You Have
There are three, they have different causes, and the fixes do not overlap.
Slow response to interaction means the JavaScript thread is blocked. A tap does not register, or registers late, because a long synchronous task is running.
Stuttering during scroll or animation means frames are being missed. The work per frame exceeds the sixteen millisecond budget, either in JavaScript or in native rendering.
Slow screen transitions mean too much work is happening at mount, usually rendering more than is visible or fetching in the wrong place.
Identify which one you have before changing anything, because the fix for one will not help the others.
Lists Are the Most Common Culprit
Long lists cause more React Native performance problems than everything else combined, and most of it comes from a small number of mistakes.
Rendering a list by mapping over an array inside a scroll view instantiates every row immediately. With a thousand rows, that is a thousand component trees built before anything appears. Use a virtualized list that renders only what is near the viewport. This is the single highest-impact change in most apps.
Once virtualized, the remaining cost is per-row work during scrolling. Row components must be memoized, and memoization only works if the props are stable — a callback defined inline in the render function is a new reference on every render and defeats it entirely. Hoist those into stable references.
Give the list a way to compute row positions without measuring. When every row is the same height, providing that information lets the list calculate layout arithmetically instead of measuring each item, which removes a substantial amount of work during fast scrolls.
Keep rows shallow. A row with deeply nested views, several shadows, and multiple absolutely positioned overlays is expensive to lay out and expensive to draw, and that cost is paid for every row that scrolls past. Flattening the hierarchy is unglamorous and effective.
Move Animations Off the JavaScript Thread
An animation driven by JavaScript state means every frame requires a round trip through the JavaScript thread. If that thread is busy with anything else — and during a screen transition it always is — frames are missed and the animation stutters.
Animations that run on the native side continue smoothly regardless of what JavaScript is doing. Enable the native driver where the animation API supports it, and for gesture-driven interactions use a library that runs the animation logic on the UI thread entirely.
The distinction shows up most visibly in swipe gestures and drag interactions, where the animation must track the finger. Anything routed through JavaScript will feel disconnected from the touch under load, and users perceive that as the app being cheap even when they cannot articulate why.
Note the constraint: only certain properties can be animated natively. Transform and opacity can; layout properties like width, height, and margin generally cannot. Prefer scale over width and translate over margin when you have the choice.
Images Deserve Explicit Attention
Images are the most common cause of memory pressure and scroll stutter in content-heavy apps.
The mistake is loading full-resolution images and letting the view scale them down. A four thousand pixel wide photograph rendered into a hundred pixel thumbnail is decoded at full size in memory. A screen with twenty of those will stutter while scrolling and may be terminated on a low-memory device.
Serve appropriately sized images from the server, sized for the display dimensions and the device pixel ratio. If you cannot control the source, resize on the client before display.
Use a caching image component rather than the basic one. Disk caching, memory caching, and progressive loading are all things you want and none of them are free by default.
Find What Is Blocking the JavaScript Thread
When interactions feel unresponsive, something is occupying the thread. The usual suspects are consistent.
Large synchronous parses. Deserializing a large API response blocks everything for the duration. Paginate the endpoint, or move the parsing off the main thread.
Expensive work in render. Sorting, filtering, or transforming a large array inside a component body runs on every render. Memoize the computation, and make sure the dependencies are actually stable or the memoization does nothing.
Over-broad state updates. A context value that changes on every keystroke re-renders every consumer, including screens the user cannot see. Split contexts by update frequency so that a fast-changing value does not drag a slow-changing tree with it.
Work performed during a transition. A screen that fetches, parses, and renders everything at mount competes with the navigation animation for the same thread, which is why the transition stutters. Defer non-critical work until after the animation completes.
Reduce Bridge Traffic
In the older architecture, JavaScript and native communicate through an asynchronous bridge, and high-frequency chatter across it is a bottleneck. Scroll handlers that fire on every event, gesture updates dispatched per frame, and frequent small updates to native views all accumulate.
Batch where you can, throttle where correctness allows, and prefer APIs that keep the interaction on the native side entirely.
The newer architecture reduces this cost meaningfully through synchronous native access and a redesigned rendering system. If you are on an older version and the bridge is genuinely your bottleneck — which you should confirm by profiling rather than assume — the upgrade is worth planning.
Start-up Time Is a Separate Problem
Time to interactive on a cold start has different causes than in-app jank.
The JavaScript bundle must be loaded and parsed before anything runs, so bundle size directly affects start-up. Audit what is being imported at the top level; a date library with all locales or an icon set imported in its entirety are common and easily fixed.
Enabling the Hermes engine improves start-up substantially because bytecode is precompiled rather than parsed at launch.
Lazy-load screens rather than importing every screen in the navigator at start-up. The first screen is the only one that must be ready immediately.
And do not fetch everything at launch. Render the shell with cached or placeholder content, then load. Perceived start-up is what users judge, and a screen that appears immediately with skeleton content feels faster than a blank screen that appears complete a second later.
Measure, Change One Thing, Measure Again
The failure mode in performance work is changing five things at once and not knowing which helped. Some optimizations are neutral, some make things worse, and memoization applied indiscriminately adds comparison cost without benefit.
Record a baseline on the slow device. Make one change. Record again. Keep the changes that moved the number and revert the ones that did not.
Most apps reach a smooth sixty frames per second by virtualizing their lists, moving animations to the native driver, sizing images correctly, and removing one or two expensive computations from render. That is a short list, and it is usually the whole job.