DEV Community

Abhijeet Srivastava
Abhijeet Srivastava

Posted on • Edited on

How React.lazy() and Suspense Cut Bundle Size and Improve Performance

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:

  • Dashboard
  • Analytics
  • Reports
  • Settings

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:

  1. the problem with static loading.
  2. how to inspect bundle size and download time in DevTools.
  3. how lazy loading splits bundles and reduces the first-load cost.
  4. 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 />}
    </>
  )
}
Enter fullscreen mode Exit fullscreen mode

At first glance, this looks harmless.

But here’s the issue:

  • Analytics tab becomes part of the initial app bundle.
  • the browser downloads that code on first load.
  • any module-level initialization inside Analytics tab also runs immediately.
  • users who never click Analytics tab 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
Enter fullscreen mode Exit fullscreen mode

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>
      )}
    </>
  )
}
Enter fullscreen mode Exit fullscreen mode

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)