How to Lazy-Load Components the Right Way (And Avoid the Trap)

React.lazy and Suspense are the right tools for code splitting in React — but lazy-loading the wrong things creates waterfalls that hurt more than they help. Here's the granularity rule that makes code splitting actually improve performance.
Every JavaScript file your app imports ends up in the bundle. Every component, every utility, every library. The browser downloads all of it before anything renders.
For small apps this doesn't matter. For apps that have grown — with charting libraries, rich text editors, date pickers, map components — the bundle gets heavy. Users on slow connections stare at a blank screen while the browser parses JavaScript they might never use.
Code splitting is the solution: break the bundle into smaller pieces and load each piece only when it's needed. React.lazy and Suspense are React's built-in tools for doing this at the component level.
They're also easy to misuse in ways that make performance worse, not better.
What React.lazy actually does
React.lazy takes a function that returns a dynamic import and wraps it in a component. The component isn't loaded until React actually tries to render it.
When Dashboard renders, React sees HeavyChart and checks: has this component been loaded? If not, it fires the dynamic import, which downloads the chunk containing HeavyChart and its dependencies. While waiting, it renders the Suspense fallback. When the download completes, it renders the real component.
The Suspense boundary is required. Without it, React doesn't know what to show while the component loads and will throw an error.
The granularity rule
This is where most developers go wrong: they reach for React.lazy at the wrong level of granularity.
Too coarse: lazy-loading a tiny component that only imports a small utility. The overhead of the dynamic import — a network round trip, a new chunk — costs more than the bytes saved.
Too fine: lazy-loading a large page component that immediately imports everything it needs anyway. The user still waits for all those dependencies — just one waterfall later.
The right level of granularity follows two principles:
Routes first. Each route is a natural code split boundary. Users navigate to routes intentionally — the brief loading moment is expected. Your settings page and your analytics page don't need to ship in the same chunk as your landing page.
Heavy components second. Within a route, identify components that are significantly heavier than their surroundings. A Markdown editor, a PDF viewer, a data visualisation library, a map — these are legitimate candidates for lazy loading because their dependencies are disproportionately large.
Named exports and the React.lazy workaround
React.lazy only works with default exports. If your component uses a named export, you need a small wrapper:
The .then() remaps the named export to the default key that React.lazy expects. Verbose but straightforward. If you control the component file, switching to a default export is cleaner.
Preloading: loading before the user gets there
The default behaviour of React.lazy is to load the component when React renders it. But you often know a component will be needed before React tries to render it — the user hovers over a button, they tab toward a section, they're approaching the bottom of a scrollable list.
Preloading fires the import early so the component is ready when React needs it:
When the user hovers, the chunk starts downloading. By the time they click and React tries to render HeavyModal, the download is usually complete or nearly complete. The loading state either never shows or shows for a fraction of a second.
Suspense boundaries: placement matters
Where you put the Suspense boundary determines what the loading state looks like.
Too high: wrapping the whole page in one boundary means the entire page shows a loading spinner when any lazy component loads. Everything else on the page disappears.
Too low: wrapping each lazy component individually shows multiple independent loading states that flash in and out. Chaotic.
Right: wrap at a meaningful UI boundary — the section, the modal, the panel that contains the lazy component. The rest of the page stays visible while that section loads.
Two independent Suspense boundaries. If RevenueChart loads slowly, TransactionTable doesn't wait for it. Both show their skeletons, both resolve independently. The header and stats are always visible.
In Next.js: next/dynamic
Next.js wraps React.lazy with additional options through next/dynamic. The API is similar but with server-side rendering control:
The ssr: false option is the critical one for Next.js. Components that use window, document, localStorage, or any other browser API will throw during server-side rendering. ssr: false tells Next.js to skip rendering this component on the server entirely and lazy-load it only in the browser.
The cost: these components don't contribute to the server-rendered HTML. They appear only after the client JavaScript runs. Use ssr: false only when necessary — for everything else, ssr: true (the default) is better for performance and SEO.
The waterfall trap
The most common way React.lazy makes things worse: creating a component waterfall.
The user waits for LazyParent to download. React renders it. React sees LazyChild and starts downloading that. Two sequential network requests where one is blocked by the other. The user sees two loading states in sequence.
The fix: don't nest lazy-loaded components. If a component is always rendered alongside another, bundle them together. Lazy-load at the boundary, not inside the lazy component.
Or preload at the parent level so both chunks download simultaneously.
Measuring the impact
Code splitting only helps if you measure it. Two tools worth using before and after adding React.lazy:
Bundle analyzer. @next/bundle-analyzer (for Next.js) or rollup-plugin-visualizer (for Vite) shows you the size of each chunk. Before lazy-loading: one large chunk. After: a smaller initial chunk plus deferred chunks. The initial chunk size is what affects first load performance.
Network tab. In Chrome DevTools, filter by JS and watch which chunks load and when. You should see the deferred chunk appear only when the route or component is first rendered, not on initial page load.
A common mistake: adding lazy-loading, checking the bundle analyzer, seeing that the total JavaScript size is unchanged, and concluding it didn't help. Total size is irrelevant — what matters is how much loads initially vs how much loads later.
The decision checklist
Before adding React.lazy to a component, run through these:
Is it a route? → Yes, lazy-load it.
Is it a heavy component with large dependencies? → Check the bundle analyzer. If removing it from the initial chunk saves more than ~20KB gzipped, lazy-load it.
Is it rendered conditionally? → A modal, a dropdown panel, a slide-out drawer that most users never open — good candidate for lazy-loading.
Is it always visible on first render? → Don't lazy-load it. The loading flash is worse than the bundle weight.
Is it small? → Don't lazy-load it. The dynamic import overhead isn't worth it.
The trap with code splitting is treating it as a performance checkbox rather than a performance decision. Lazy-loading everything doesn't improve performance — it just moves the loading from page load to component render, often creating worse experiences than doing nothing.
Lazy-load routes. Lazy-load the components that are genuinely heavy and genuinely optional. Measure before and after. That's the pattern that actually works.
Up next: React reconciliation explained — what the diffing algorithm actually does — the mental model that makes key, memo, and re-renders make sense at a deeper level.
Related: Next.js build output analysis — finding what's making your bundle large — the bundle analyzer workflow that tells you where lazy-loading will actually help.
Alex Chen is a senior frontend engineer who writes about React patterns, JavaScript internals, and the decisions that separate maintainable codebases from ones that fight back. Opinionated by design.
Photo by Mike van den Bos on Unsplash

