How deferring the render of inactive tabs cuts load time and what trade off comes with it
A bottom tab navigation with five or six tabs will, without deliberate configuration, sometimes render every included screen right at the very first app launch, even though the user initially only ever sees the first tab. Lazy loading pushes rendering a tab to the moment it actually becomes active for the first time, and can noticeably shorten the perceived startup time of a tab based app. That effect comes at a price though, when a previously visited tab loses its internal state on switch. This article covers the practical configuration, the concrete trade off, and how the effect can actually be measured.
Table of Contents
- 1. What lazy loading concretely means for tab navigators
- 2. Default behavior versus explicit configuration
- 3. The trade off: losing state on tab switch
- 4. freezeOnBlur and detachInactiveScreens in detail
- 5. Practical configuration in code
- 6. Lazy behavior of the Tabs component in Expo Router
- 7. Weighing memory usage against load time
- 8. Actually measuring the effect instead of just assuming it
- 9. Combining lazy loading with Suspense and per tab data loading
- 10. Summary
- 11. FAQ
1. What lazy loading concretely means for tab navigators
Without lazy loading, a bottom tab navigator by default initializes every configured tab screen simultaneously on the navigator's first render, even though only a single tab is actually visible. Every one of these screens runs through the full React lifecycle including every contained useEffect hook, meaning a data fetch inside a fourth tab, for example, already runs even though the user may never open that tab at all.
With lazy loading enabled, a tab screen only actually mounts once it gets selected as active for the first time, reducing the amount of work done at initial app launch to exactly the one tab that is actually visible. Once a tab has been visited, it generally stays kept in memory, so switching back to a previously opened tab does not trigger another mount, only a visibility change.
2. Default behavior versus explicit configuration
In newer versions of @react-navigation/bottom-tabs, lazy loading is already enabled by default, which in many existing projects means developers never deliberately configured the effect but are already using it implicitly. It is still worth setting it explicitly through the lazy option on the relevant Tab.Navigator, because that visibly documents in the code that this behavior was deliberately chosen rather than incidentally present.
For individual tabs where immediate preloading is actually desired, such as a home tab with data needed right after opening the app anyway, lazy loading can be disabled specifically through the lazy option at the individual screen level, while it stays active for every other tab. This mixed configuration is often more useful in practice than a blanket toggle for the entire navigator.
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
const Tab = createBottomTabNavigator();
export function MainTabs() {
return (
<Tab.Navigator screenOptions={{ lazy: true }}>
<Tab.Screen
name="Home"
component={HomeScreen}
options={{ lazy: false }} // deliberately loaded right away
/>
<Tab.Screen name="Search" component={SearchScreen} />
<Tab.Screen name="Orders" component={OrdersScreen} />
<Tab.Screen name="Account" component={AccountScreen} />
</Tab.Navigator>
);
}
3. The trade off: losing state on tab switch
Lazy loading only affects the timing of the first mount, not the behavior on subsequent tab switches, which is a common source of confusion: by default, a tab screen that has already mounted once stays permanently in memory even while invisible, so its internal state, such as a form input or a scroll position, survives switching back to it. If detachInactiveScreens or a similar optimization through react-native-screens gets enabled on top, inactive screens actually get removed from the native view tree instead, which saves memory but can destroy local component state in the process.
This distinction is what decides which optimization makes sense for which tab: a tab holding a long, unchanging list benefits from aggressive memory cleanup, because rebuilding it is barely noticeable, while a tab holding a partially filled form or a carefully scrolled position can turn the same optimization into a noticeably worse experience, when the input suddenly vanishes on reopening.
4. freezeOnBlur and detachInactiveScreens in detail
react-native-screens offers freezeOnBlur as a more fine grained alternative to fully removing a screen: instead of unmounting the screen completely, it merely pauses all re-renders and effects while the native view stays present in memory and in the view tree. That keeps the full React state, including form inputs and scroll position, fully intact while still avoiding unnecessary background render work, which in most cases represents the best compromise between memory usage and state retention.
The more aggressive detachInactiveScreens option, on the other hand, fully removes inactive screens from the native view tree, which noticeably lowers memory usage in apps with a lot of tabs or particularly memory heavy screens, but as described discards any local state in the process. A sensible middle ground for most production apps is enabling freezeOnBlur globally and turning on detachInactiveScreens only for specific, known memory hungry tabs, rather than setting it across the whole navigation blanket style.
5. Practical configuration in code
freezeOnBlur gets configured through the tab navigator's screenOptions and applies uniformly to every contained screen unless an individual screen explicitly overrides it. For targeted detachInactiveScreens at the level of a single stack navigator nested inside one tab, the option can be set directly on that nested navigator, so only that specific area is affected while the rest of the app stays unchanged.
In practice it is worth documenting this configuration in a central spot within the navigation setup, for example with a comment explaining why a particular tab deviates from the default configuration, so later changes to the navigation tree do not accidentally undo a deliberately made performance decision.
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import { enableFreeze, enableScreens } from 'react-native-screens';
enableScreens();
enableFreeze(true); // global default for every screen
const Tab = createBottomTabNavigator();
export function MainTabs() {
return (
<Tab.Navigator screenOptions={{ lazy: true, freezeOnBlur: true }}>
<Tab.Screen name="Home" component={HomeScreen} options={{ lazy: false }} />
<Tab.Screen name="Feed" component={FeedScreen} />
{/* Feed holds many images, targeted detach pays off here */}
<Tab.Screen
name="Media"
component={MediaStack}
options={{ freezeOnBlur: false, detachInactiveScreens: true }}
/>
</Tab.Navigator>
);
}
6. Lazy behavior of the Tabs component in Expo Router
Expo Router's Tabs component builds internally on the same bottom tab navigator from React Navigation and inherits its lazy loading behavior largely unchanged, so the same options such as lazy and freezeOnBlur can be set there through screenOptions as well. The main difference is that Expo Router automatically derives tab screens from the folder structure inside the app/(tabs) directory instead of manually listing them as Tab.Screen elements, which makes the configuration look syntactically different without changing the underlying behavior.
Because Expo Router additionally combines URL based routing with tab navigation, it matters that a direct deep link to a deeply placed tab screen bypasses that screen's lazy loading behavior, since the target tab must be mounted immediately in that case to render the link correctly. This behavior is intentional but worth accounting for during performance planning, since a tab opened through a deep link does not benefit from deferred initialization.
7. Weighing memory usage against load time
For an app with only three or four fairly simple tabs, aggressive memory optimization through detachInactiveScreens often brings barely noticeable benefit, since the extra memory footprint of every mounted tab stays low anyway, while the trade off of lost state on every tab switch stands out as a noticeably negative change. For an app with eight or more tabs, some containing heavy lists, maps or media grids, that balance often flips, since the memory usage of every simultaneously active screen can genuinely cause noticeable stutter or even crashes on older devices.
A practical rule of thumb is grouping tabs into three categories: tabs with unchanging, easily reloadable content benefit from aggressive detachment, tabs holding important, hard to reconstruct local state should use freezeOnBlur instead of detachment, and a single, particularly frequently used start screen tab should generally be excluded from lazy loading entirely.
8. Actually measuring the effect instead of just assuming it
To objectively demonstrate the effect of lazy loading, a simple comparison of the time to interactive metric using the React Native Performance Monitor overlay or a dedicated tool such as react-native-performance works well, measured once with lazy loading fully disabled and once with the default configuration. A measurement on an actually older mid range Android device is particularly telling, since the difference on a current high end device is often barely perceptible thanks to its raw performance headroom, while it becomes clearly visible on weaker hardware.
Beyond pure load time, it is worth observing memory usage through the native profiling tools of Android Studio or Xcode Instruments, to check whether a chosen combination of lazy, freezeOnBlur and detachInactiveScreens actually achieves the expected effect, instead of relying exclusively on the documentation of the respective library, whose behavior can genuinely change between versions.
9. Combining lazy loading with Suspense and per tab data loading
Lazy loading at the navigator level and data loading inside a screen can be combined sensibly, by having a tab screen only start its associated data fetch on its own mount, triggered by the tab becoming active for the first time, instead of preloading data for every tab already at app launch. Combined with React Suspense and a matching data library, that loading step can additionally be cushioned visually with a skeleton screen, so the otherwise unavoidable first load of a tab does not register as a noticeable delay.
What matters here is not repeating the data fetch every time an already mounted tab becomes visible again, but tying it to the first mount through a suitable cache mechanism, such as React Query or SWR, and simply displaying the cached data again on subsequent tab switches for as long as it has not been marked stale.
| Option | Effect | State after tab switch | Recommended for |
|---|---|---|---|
| lazy: true | Screen mounts only on first activation | Preserved after first mount | Every tab except the start screen |
| lazy: false | Screen mounts immediately at app launch | Always preserved | Frequently used home tab |
| freezeOnBlur: true | Re-renders and effects paused, view stays intact | Fully preserved | Tabs with forms or an important scroll position |
| detachInactiveScreens: true | Screen fully removed from the view tree | Discarded | Memory heavy tabs with unchanging content |
| Suspense + per tab cache | Data fetch tied to first mount | Cached data stays visible | Tabs with their own data loading |
Mironsoft
React Native app development and Magento integration
A mobile app for the Magento shop that actually runs smoothly?
We build React Native apps cleanly connected to the Magento REST or GraphQL API, from the first line of code to publishing on the App Store and Google Play.
App Concept
Plan the architecture and feature scope of a Magento-connected app together.
Magento API Integration
Cleanly connect product catalog, cart, and checkout to the shop API.
Store Publishing
Guide the App Store and Google Play release process without pitfalls.
10. Summary
Tab Navigation Performance: Key Takeaways
Effect
Lazy loading defers mounting a tab to the moment of its first activation and cuts initial load time this way.
Trade off
Aggressive memory optimization such as detachInactiveScreens discards local state on tab switch.
Middle ground
freezeOnBlur pauses re-renders without losing state and is usually the best compromise.
Measurement
The effect shows up mainly on older mid range devices, much less on current high end devices.