Recently, I was experimenting with React performance and wanted to answer a simple question:
How much can a single unused tab cost a React app on first load?
To find out, I built a small React application with an intentionally heavy tab and compared static imports with React.lazy(). The results were more significant than I expected.
Most websites ship more JavaScript than users actually need on first load.
A common example is a dashboard with multiple tabs:
DashboardAnalyticsReportsSettings
In a typical implementation, all four tab components are imported directly at the top of the file, so React can render whichever tab is active.
Here's the catch: if Analytics is imported statically, every user pays for that code on initial load, even if they never open that tab.
In this post, I’ll show:
- the problem with static loading.
- how to inspect bundle size and download time in DevTools.
- how lazy loading splits bundles and reduces the first-load cost.
- how the heavy analytics bundle gets loaded only when the user clicks the tab.
The Problem with Static Loading
For this example, I'll focus on just two of those tabs — Dashboard and Analytics — since that's enough to show the pattern clearly.
In the static version, the Analytics tab is imported like this:
import Dashboard from './tabs/Dashboard'
import Analytics from './tabs/Analytics'
export default function App() {
const [tab, setTab] = useState('dashboard')
return (
<>
<button onClick={() => setTab('dashboard')}>Dashboard</button>
<button onClick={() => setTab('analytics')}>Analytics</button>
{tab === 'dashboard' ? <Dashboard /> : <Analytics />}
</>
)
}
At first glance, this looks harmless.
But here’s the issue:
-
Analyticstab becomes part of the initial app bundle. - the browser downloads that code on first load.
- any module-level initialization inside
Analyticstab also runs immediately. - users who never click
Analyticstab still pay the cost.
This is exactly the kind of hidden performance tax that accumulates in real apps.
The Example Setup
This example is built with Vite. I intentionally made the Analytics component heavy, so the difference would be clearly visible in the browser's Network tab.
It includes:
- a large static payload bundled only with that tab.
- module-level processing that runs as soon as the component loads.
A note on testing it locally:
Running it with yarn dev wouldn't have shown the real picture — Vite serves source modules individually in dev mode, which is fine for development but doesn't reflect actual production bundle sizes.
To see the real chunk sizes, I built and previewed the production version instead:
yarn build
yarn preview
I also kept Disable cache checked in Chrome DevTools' Network tab, so every screenshot reflects a genuine fresh network request rather than cached results.
That's what let me see the actual network behavior shown in the screenshots below.
Static Loading: What Happens
Here's what the static import version looks like in practice.
First, here's the production build output — the main bundle comes to 586.58 KB (280.86 KB gzip), and notice that the Analytics code is already part of it:
And here's the Network tab on initial page load — the Analytics code loads as part of the main bundle itself, before the user has ever opened that tab:
This has a few consequences:
- the first bundle is larger as it includes Analytics code as well.
- the initial download takes longer.
- parsing and evaluating JavaScript takes longer.
- users pay for optional UI up front, whether they use it or not.
This is the wrong tradeoff when the feature is not immediately needed.
That’s the key point:
The user has not clicked the Analytics tab yet, but the browser has already downloaded the code for it.
Fixing It with React.lazy() and Suspense
Here's the fix — instead of statically importing the Analytics tab, I deferred it using lazy():
import { lazy, Suspense, useState } from 'react'
import Dashboard from './tabs/Dashboard'
const Analytics = lazy(() => import('./tabs/Analytics'))
export default function App() {
const [tab, setTab] = useState('dashboard')
return (
<>
<button onClick={() => setTab('dashboard')}>Dashboard</button>
<button onClick={() => setTab('analytics')}>Analytics</button>
{tab === 'dashboard' ? (
<Dashboard />
) : (
<Suspense fallback={<div>Loading Analytics...</div>}>
<Analytics />
</Suspense>
)}
</>
)
}
Here's the production build output with this change. Notice Analytics is no longer part of the main bundle — it's now its own separate chunk, and the main bundle has dropped to 194.06 KB (61.22 KB gzip):
And here's the Network tab on initial page load — only the Dashboard and core app code load upfront; the Analytics chunk isn't requested at all yet:
The moment I click the Analytics tab, the browser fetches that chunk on demand:
This changes behavior in an important way:
- the initial bundle still includes Dashboard, but no longer includes Analytics.
- the first-load download is smaller and faster, since Analytics isn't part of it.
- the browser only requests the Analytics chunk when the user actually clicks that tab.
Build comparison
| Static | Lazy | |
|---|---|---|
| Main bundle | 586.58 KB | 194.06 KB |
| Gzip | 280.86 KB | 61.22 KB |
The result is a 66.9% reduction in the main bundle and a 78.2% reduction in the compressed JavaScript downloaded on first load.
That's the real value of lazy loading:
Reduce the JavaScript required for first paint, and defer optional feature code until the user actually needs it.
Why This Matters in Real Products
This pattern is useful whenever part of the UI is optional, secondary, or rarely visited.
It shows up not only in tabs like this, but also in components that live on specific routes — a heavy modal only reachable from a settings page, or a report screen that pulls in a heavy charting library only that page needs.
If the feature is not needed immediately, it usually should not be in the initial bundle.
One Important Caveat
Lazy loading is not magic.
It doesn't remove the cost of the code — it moves the cost from initial load to the moment the user actually needs that feature.
So the right question is not:
Can I lazy load this?
The right question is:
Should every user pay for this code on the first screen?
If the answer is no, lazy loading is often the right move.
Final Thought
React.lazy() and Suspense are really about one question:
Who should pay for this code, and when?
By default, every user downloads the code, whether they need the feature or not. Lazy loading shifts that cost to the moment it's actually needed, making the initial experience faster for everyone.





Top comments (0)