React Native Performance at Scale
bankingAugust 10, 2026

React Native Performance at Scale

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. 


Why Banking Apps Are a Harder Performance Problem 


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. 


JS Thread Bottlenecks 


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. 

Unbatched state updates from real-time data. 

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. 


Heavy libraries on the JS thread. 

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. 


Bridge Overhead (and Why the New Architecture Changes the Calculus) 


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. 


List Virtualization: Where Transaction Screens Live or Die 

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: 


  • Estimated item size matters more than it looks. An inaccurate estimatedItemSize (FlashList) or getItemLayout (FlatList) causes layout thrashing as the list recalculates positions during scroll. We measure actual rendered row heights per density tier (compact vs. expanded transaction view) and set this explicitly rather than relying on defaults. 
  • Windowing configuration should reflect real usage, not defaults. maxToRenderPerBatch, windowSize, and initialNumToRender all trade off memory and render smoothness against how far ahead of the visible viewport content is prepared. For transaction lists where users tend to scroll in bursts, we widen the window slightly beyond default to avoid visible blank-cell flashes, while capping it to avoid memory pressure on lower-end devices. 
 1  2   data={transactions} 
 3   renderItem={renderTransactionRow} 
 4   estimatedItemSize={72} 
 5   keyExtractor={(item) => item.id} 
 6   onEndReachedThreshold={0.5} 
 7   onEndReached={loadMoreTransactions} 
 8 /> 
  • Pagination over infinite unbounded fetch. We page transaction data server-side in chunks (typically 30–50 records) rather than fetching full history client-side. This keeps both the JS-side data structure and the rendered list bounded, which matters for both performance and for memory behavior on devices with limited RAM. 
  • Avoid inline function and object props in renderItem. New function references on every render defeat memoization at the row level. We define renderItem and row components outside the render path, or wrap with useCallback, so FlashList's recycling can actually skip unnecessary re-renders. 


Measuring What Matters 


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: 

  • JS frame rate and drop count during the highest-interaction flows (transaction list scroll, transfer confirmation), measured via Flipper's performance monitor 
  • Time to interactive on the dashboard screen, from cold start through first render of live balance data 
  • Bridge/JSI call volume on screens with real-time updates, to catch regressions before they ship 
  • Memory footprint during extended transaction list scrolling, tested against the lowest-spec device in the supported device matrix 

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. 


The Takeaway 

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.