EmpireUI
Get Pro
← Blog9 min read#react.lazy#code splitting#performance

React.lazy and Code Splitting: Route-Level, Component-Level, Preloading

Split your React bundle the right way. Route-level lazy loading, component-level splits, and smart preloading strategies that actually move your Lighthouse score.

JavaScript code on dark screen with split panel editor view

Why Your Bundle Is Probably Too Big

If you bootstrapped a React app with Create React App or Vite and never touched code splitting, you're shipping one monolithic JavaScript file to every visitor — even the ones who just land on your homepage and bounce. In 2025, the median React SPA main bundle was sitting around 280 kB gzipped. That's a lot of JS to parse before a user sees anything interactive.

Parse time is the real killer. Download speeds have gotten better on mobile, but CPU-bound parse and compile time has not kept pace. A 400 kB bundle on a mid-range Android device in 2024 can block the main thread for 3–4 seconds. Your LCP suffers, your INP suffers, and your users leave. Code splitting doesn't just help Lighthouse vanity scores — it's a real user experience problem.

Honestly, most teams reach for React.lazy too late. They wait until the bundle becomes obviously painful before splitting anything. The better mental model is to treat splitting as a default, not an optimization — you opt routes and heavy components *in* to the main bundle only when you have a good reason.

One more thing — React.lazy isn't magic. It hands the lazy-loading problem to your bundler (Webpack, Vite, Rollup, whatever you're using). The bundler creates separate async chunks. React.lazy just gives you a way to load those chunks at runtime on demand, wrapped in a Suspense boundary. Understanding that layering matters when things go wrong.

Route-Level Splitting: The Baseline

Route-level splitting is table stakes. Every route that isn't your initial landing page should be lazy-loaded. The pattern is straightforward with React Router v6 — you wrap each route component in React.lazy, then wrap your <Routes> tree in a <Suspense> boundary with a fallback. ``tsx import { lazy, Suspense } from 'react' import { Routes, Route } from 'react-router-dom' const Dashboard = lazy(() => import('./pages/Dashboard')) const Settings = lazy(() => import('./pages/Settings')) const Analytics = lazy(() => import('./pages/Analytics')) export function AppRouter() { return ( <Suspense fallback={<PageLoader />}> <Routes> <Route path="/" element={<Home />} /> <Route path="/dashboard" element={<Dashboard />} /> <Route path="/settings" element={<Settings />} /> <Route path="/analytics" element={<Analytics />} /> </Routes> </Suspense> ) } ``

That said, the fallback component matters more than people think. A <PageLoader /> that flashes in for 80ms and out again can feel *worse* than no spinner at all — it introduces layout shift. You want a skeleton that matches the target page's rough structure, or a bare minimum 200ms delay before showing anything. You'd be surprised how much that polish changes perceived performance.

Worth noting: placing a single <Suspense> at the router level is fine to start, but you lose granularity. If your /dashboard route has a sidebar that loads fast and a chart area that's heavy, a single route-level boundary means the entire page waits. That's where component-level splitting comes in.

Quick aside: in Next.js 13+ with the App Router you don't use React.lazy directly for routes — the file-system routing handles that. But you still use dynamic() from next/dynamic for component-level splits, which is essentially the same pattern with a Next-specific wrapper.

Component-Level Splitting: Getting Surgical

Route splitting gets you maybe 30–50% of the way there. The rest comes from splitting *within* routes. Think about a dashboard page: it might import a rich text editor, a data grid, a PDF viewer, a charting library. Recharts alone can add 60 kB gzipped. If a user opens the dashboard but never touches the "Export to PDF" button, why did they pay for that weight? ``tsx import { lazy, Suspense, useState } from 'react' const PDFExporter = lazy(() => import('./components/PDFExporter')) const ChartPanel = lazy(() => import('./components/ChartPanel')) export function Dashboard() { const [showPDF, setShowPDF] = useState(false) return ( <div> <h1>Dashboard</h1> <Suspense fallback={<ChartSkeleton />}> <ChartPanel /> </Suspense> <button onClick={() => setShowPDF(true)}>Export PDF</button> {showPDF && ( <Suspense fallback={<div>Loading exporter...</div>}> <PDFExporter /> </Suspense> )} </div> ) } ``

In practice, the things worth splitting at the component level are: rich text editors (Tiptap, Quill, Slate all balloon to 150–250 kB), data visualization libraries, code editors (Monaco adds ~2 MB unminified), PDF and image processing, and any third-party widget that isn't used on every page view. Anything under about 10 kB gzipped? Don't bother — the extra HTTP round-trip and chunk overhead isn't worth it.

Nesting <Suspense> boundaries is legal and often useful. You can have a route-level boundary at the top that catches everything, then tighter boundaries around the heavy components within that route for more specific fallback UI. React cascades to the nearest boundary, so the innermost matching <Suspense> wins. This lets you show a skeleton for just the chart area while the rest of the page renders immediately.

Look, one mistake I see constantly: wrapping *everything* in lazy just because you can. Your <Button>, <Input>, your 2 kB utility components — leave them in the main bundle. The split overhead (an extra network request, a brief Suspense flash) is only worth it when the component is genuinely heavy. Be intentional.

Named Exports and the Default Export Gotcha

Here's the thing that trips people up: React.lazy only accepts a function that resolves to a module with a default export. If your component uses a named export, you need a tiny wrapper: ``tsx // This WON'T work if ChartPanel is a named export const ChartPanel = lazy(() => import('./components/ChartPanel')) // Do this instead const ChartPanel = lazy(() => import('./components/ChartPanel').then((mod) => ({ default: mod.ChartPanel, })) ) ``

That's not a React limitation so much as a module spec thing. The .then() reshaping is idiomatic and Vite/Webpack both understand it. Alternatively, just add a default export to your component file — some teams enforce "default export for every lazy-loaded component" as a lint rule, which honestly makes the codebase easier to grep.

Worth noting: TypeScript and lazy play fine together, but you need the generic form in some edge cases. If your lazy component accepts typed props, React.lazy<ComponentType<Props>>(() => import(...)) keeps type safety intact. Without the generic, TypeScript sometimes infers any on the resulting component.

Preloading: Loading Before the User Asks

Lazy loading on-demand is good. Preloading *right before* the user probably wants something is better. The canonical pattern is preloading on hover or focus — you start fetching the chunk the moment the user hovers over a navigation link, so by the time they click, the chunk is already in the cache. ``tsx const SettingsPage = lazy(() => import('./pages/Settings')) // Trigger preload imperatively function preloadSettings() { import('./pages/Settings') } export function NavLink() { return ( <a href="/settings" onMouseEnter={preloadSettings} onFocus={preloadSettings} > Settings </a> ) } ``

The key insight is that calling import('./pages/Settings') and calling it again inside React.lazy both reference the same dynamic import — the bundler deduplicates it. So your preload call and your lazy call are hitting the same promise. Once it's resolved, React serves it from cache instantly. No second network request.

Vite (as of v5.0 in 2024) gives you /* @vite-prefetch */ and /* @vite-preload */ magic comments for more declarative control. Webpack has had /* webpackPrefetch: true */ and /* webpackPreload: true */ since Webpack 4.6. prefetch adds a <link rel="prefetch"> hint — great for things the user *might* visit. preload adds <link rel="preload"> — use it for things that will *definitely* be needed soon (like the next step in a wizard flow). ``tsx // Webpack: prefetch — low-priority, fetched in browser idle time const Step2 = lazy(() => import(/* webpackPrefetch: true */ './Step2')) // Webpack: preload — higher priority, fetched alongside current chunk const CriticalModal = lazy(() => import(/* webpackPreload: true */ './CriticalModal')) ``

One more thing — for UI-heavy apps like the kind you'd build with Empire UI components, preloading on hover is especially valuable. Glassmorphism panels, animated modals, and canvas-based components can get chunky. Rather than showing a spinner when a user clicks a button to open an overlay, preload the overlay's chunk when they hover the trigger. The interaction feels instant. You can see this in action across our glassmorphism components — some of those animated layers would be painful to ship synchronously on every page load.

Measuring What You Actually Split

You can't optimize what you don't measure. Open your Vite or Webpack build output and look at the chunk sizes. With Vite, vite build --report gives you a visual treemap. With CRA or Webpack, source-map-explorer or webpack-bundle-analyzer are the go-tos. You want to see a bunch of small async chunks, not one giant main.js. ``bash # Vite build analysis npx vite build --mode production npx vite-bundle-visualizer # Webpack / CRA npx source-map-explorer 'build/static/js/*.js' ``

A quick rule of thumb: your initial bundle (the JS that loads before any splitting kicks in) should stay under 150 kB gzipped for most apps. Everything else should be async. If your initial chunk is 500 kB, you're either not splitting, or you have something big imported in your app entry point that shouldn't be there. Common culprits: importing a heavy utility library in index.tsx, a barrel file (index.ts) that re-exports 200 components and forces Webpack to include everything, or a CSS-in-JS runtime that bundles theme tokens.

Network tab in DevTools is your friend too. Load your app, click through routes, and watch for *.js chunks appearing in the network waterfall. Each lazy route should trigger a new chunk request the first time you visit it. If you're seeing 1–2 big requests and nothing after that, you're not actually splitting. Verify that your build config has splitChunks enabled (Webpack) or build.rollupOptions.output.manualChunks configured (Vite/Rollup).

In practice, combining route splitting with a few strategic component-level splits usually gets you from a 400 kB+ initial bundle down to under 100 kB. That's a 4x improvement in parse work on first load, which translates directly to a better FID/INP score — especially on lower-end hardware. The gradient generator and other heavy tools on Empire UI use exactly this pattern to stay snappy even on mobile.

Error Boundaries: Don't Skip Them

One part of the React.lazy setup that's easy to skip: error boundaries. A lazy chunk can fail to load — bad network, CDN hiccup, chunk hash mismatch after a deploy. If you don't have an error boundary, that failure will crash the entire React tree silently (or show a blank screen). ``tsx import { Component, type ReactNode } from 'react' interface Props { children: ReactNode fallback?: ReactNode } interface State { hasError: boolean } export class ChunkErrorBoundary extends Component<Props, State> { state: State = { hasError: false } static getDerivedStateFromError() { return { hasError: true } } componentDidCatch(error: Error) { // Log to your error tracker here console.error('Chunk failed to load:', error) } render() { if (this.state.hasError) { return this.props.fallback ?? <button onClick={() => window.location.reload()}>Reload page</button> } return this.props.children } } // Usage <ChunkErrorBoundary fallback={<p>Failed to load this section. <a href=".">Refresh</a></p>}> <Suspense fallback={<Spinner />}> <HeavyComponent /> </Suspense> </ChunkErrorBoundary> ``

Error boundaries have to be class components — there's no hook equivalent as of React 19. The react-error-boundary package from Brian Vaughn is the community standard if you don't want to write the class boilerplate. It adds onReset, resetKeys, and a useErrorBoundary hook that pairs cleanly with lazy loading.

After a deploy, users with the old app version cached in their browser will request chunk filenames that no longer exist on your CDN (because content hashes changed). This is the most common real-world cause of lazy-load failures. Your error boundary's fallback should prompt a page reload — that's usually enough to fix it. Some teams check for a version mismatch on route change (comparing a version token in the HTML against a /version.json endpoint) and proactively reload before the user hits a chunk error.

Honestly, an error boundary around every Suspense-lazy combo is the kind of defensive pattern that separates production-grade apps from side projects. Takes 10 minutes to add. Saves you an incident at 2 AM.

FAQ

Does React.lazy work with server-side rendering?

Not directly — React.lazy is client-only. For SSR, you need next/dynamic in Next.js, or loadable-components in a custom SSR setup. Those libraries handle the server render and hydration dance that React.lazy skips.

Can I lazy load a component that uses hooks?

Yes, hooks work fine inside lazy-loaded components. The component itself loads asynchronously, but once it's mounted, all React rules apply normally. The hooks don't care how the component arrived in the tree.

What's the difference between webpackPrefetch and webpackPreload?

Prefetch (<link rel="prefetch">) is low-priority — the browser fetches it during idle time, for resources the user *might* need later. Preload (<link rel="preload">) is higher-priority, used for resources that will *definitely* be needed soon in the current navigation. Use prefetch for optional routes, preload for the next step in a known flow.

How small does a component need to be before splitting isn't worth it?

Rough threshold: don't bother splitting anything under about 10 kB gzipped. The async chunk request, the Suspense boundary overhead, and the potential flash of fallback UI all add friction. Reserve splitting for genuinely heavy imports — chart libraries, editors, exporters.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

React Lazy Loading: Code Splitting, Suspense and the Right WayReact Suspense Boundaries: Where to Put Them and WhyServer-Side Streaming in React + Next.js: Suspense and RSCIntersection Observer Advanced Patterns: Lazy Load, Sentinel, Analytics