React Native Performance at Scale
Profiling a Banking App Used by Millions
Profiling a Banking App Used by Millions
A banking app doesn't get to be slow. Balance checks, transfers, statement scrolling — these happen dozens of times a day, and every dropped frame chips away at a customer's confidence that their money is being handled by something solid. At OceanoBe, we build and maintain React Native applications for banks operating at millions-of-users scale, and performance work here isn't a nice-to-have sprint item. It's a discipline we design for from the first architectural decision, not a fire we put out after users complain.
Let's go over some insights from our experts about where React Native banking apps actually lose performance at scale, and the specific techniques we use to keep them fast: JS thread bottlenecks, bridge overhead, and list virtualization.
Most performance guides assume a social feed or an e-commerce catalog. Banking apps have a different profile. Transaction history screens can render thousands of rows with rich metadata. Merchant logos, category icons, running balances, pending-state indicators... Dashboards aggregate real-time data from multiple accounts and cards. Security requirements mean more computation per screen: biometric checks, encrypted local storage reads, tokenized session validation — all before a single pixel of financial data renders.
To that user base add another layer that skews toward older devices in some markets, and unforgiving expectations: a banking app that stutters reads as untrustworthy in a way a stuttering photo app never will.
React Native's classic architecture (and, differently, the New Architecture with JSI) still puts a meaningful amount of work on a single JavaScript thread. Everything from state updates to list re-renders to date formatting competes for that thread. When it's busy, touch responses queue up and animations judder — even if the native UI thread is completely idle.
We've traced JS thread saturation in the context of transaction-heavy screens, to a few repeat offenders:
Expensive computation in render. Formatting currency, computing running balances, or transforming API payloads inside a component body means that work reruns on every re-render, not just when the underlying data changes.
1 // Costly: recalculates on every render
2 function TransactionRow({ transaction }) {
3 const formattedAmount = formatCurrency(transaction.amount, transaction.currency);
4 const runningBalance = computeRunningBalance(transaction, allTransactions);
5 return
;
6 }
7
8 // Better: memoize, and push aggregation upstream
9 const formattedAmount = useMemo(
10 () => formatCurrency(transaction.amount, transaction.currency),
11 [transaction.amount, transaction.currency]
12 );
Running balances in particular should be computed once, server-side or in a selector layer, not recalculated per row on the client.
Balance and transaction-status updates arriving over WebSocket connections can trigger a cascade of re-renders if each message dispatches its own state update. We batch incoming updates on a short interval (100–150ms) and apply them as a single state transition, which keeps the JS thread from being interrupted dozens of times a second during active trading or payment windows.
Chart rendering for spending insights is a common culprit — some charting libraries perform layout and path calculation in JS. Where possible, we move that work to native modules or pre-aggregate on the backend so the client is just drawing pre-computed points.
We profile all of this with Flipper's Hermes debugger and the React DevTools Profiler, looking specifically at commit duration on the screens users hit most: home dashboard, transaction list, transfer confirmation.
On the classic bridge architecture, every call between JavaScript and native code is serialized to JSON, batched, and passed asynchronously. For occasional calls this is invisible. For high-frequency communication — scroll position tracking, gesture handling, real-time balance widgets that animate on native view props — it becomes a measurable cost, both in serialization overhead and in the latency of round trips.
The practical mitigation on classic architecture is to minimize bridge crossings: batch native calls, avoid firing JS callbacks on every scroll event when a throttled or native-driven equivalent will do, and use useNativeDriver: true for animations so they run without touching the bridge per frame at all.
1 Animated.timing(balanceOpacity, {
2 toValue: 1,
3 duration: 300,
4 useNativeDriver: true, // runs on the native/UI thread, not the JS bridge
5 }).start();
We've migrated recent banking projects to React Native's New Architecture (Fabric + TurboModules), which replaces the async bridge with JSI — direct, synchronous JS-to-native calls without serialization. For screens with frequent native interaction (biometric prompts, secure PIN entry, camera-based check deposit), this has measurably reduced input latency. It's not a silver bullet: it doesn't fix JS thread saturation from render logic, and migrating a mature banking codebase to the New Architecture requires auditing every native module dependency for compatibility before go-live. We treat that audit as its own workstream, not a footnote.
A transaction history screen with an unbounded scroll and no virtualization will eventually render every row that's ever been fetched into memory simultaneously. On a mid-range Android device with a few thousand transactions loaded, that's the difference between a smooth scroll and a frozen UI.
FlatList handles basic virtualization out of the box, but its defaults aren't tuned for the density and complexity of a transaction row (icon, merchant name, category tag, amount, running balance, pending indicator). We've had more consistent results with FlashList on high-density lists, largely due to its recycling model — it reuses component instances rather than mounting and unmounting them on every scroll, which matters when each row carries several child components and a memoized formatter.
Key tuning decisions, regardless of library:
1 2 data={transactions}
3 renderItem={renderTransactionRow}
4 estimatedItemSize={72}
5 keyExtractor={(item) => item.id}
6 onEndReachedThreshold={0.5}
7 onEndReached={loadMoreTransactions}
8 />
None of this is worth doing without a profiling baseline against real device tiers, not just the developer's flagship phone. Our standard measurement set for banking clients includes:
We track these as regression gates in CI where feasible, not just as one-time audits, because performance in a banking app degrades quietly — a few unmemoized components added over several sprints, and six months later the transaction screen that used to feel instant doesn't.
Performance in a high-traffic banking app isn't a single fix — it's a set of disciplines applied consistently: keeping expensive work off the JS thread, minimizing and batching bridge crossings, tuning list virtualization to the actual shape of your data, and measuring against real device constraints rather than best-case hardware. Get these right from the architecture stage, and a banking app can handle millions of users and thousands of transactions per account without ever feeling like it's straining to keep up.