Code Splitting & Lazy Loading
Split the bundle so the user doesn’t download code they might never need. Lazy-load routes and heavy components with lazy + Suspense (or framework equivalents) and optionally preload on hover or when likely needed.
The problem I keep seeing
A single JS bundle pulls in every route and every heavy component (charts, editors, admin UI). The initial load is slow, especially on slow networks. You want to load only what’s needed for the first screen and fetch other chunks when the user navigates or when a heavy component is about to be shown.
Naive approach
Static imports for everything. The bundler puts it all in one (or few) chunks; the user pays the cost up front even for code that runs only on a specific route or after a click.
First improvement
Use React.lazy for components that are heavy or conditionally rendered. Wrap them in Suspense with a fallback so the user sees a placeholder while the chunk loads. Each lazy(() => import(...)) becomes a separate chunk that loads on first render.
Remaining issues
- Where to split: Routes are the obvious boundary (one chunk per route). Also split modals, tabs, and below-the-fold content that may never be seen.
- Preloading: To reduce delay when the user clicks, preload the chunk on link hover or when a route is likely (e.g. after login, preload dashboard).
- SSR:
lazydoesn’t run on the server by default; use framework support (e.g. Next.jsdynamic) for SSR-safe lazy loading.
Production pattern
In Next.js, use dynamic for client-only or heavy components; routes are already split. In a React SPA, lazy-load route components and wrap with Suspense. Use a skeleton or small spinner as fallback. Optionally preload: call import('./Page') on onMouseEnter of the link so the chunk is ready when the user clicks.
When I use this
- Routes: One chunk per route (or per section) so the initial bundle is small.
- Heavy components: Charts, rich editors, modals that aren’t shown immediately.
- Skip when: The component is tiny or always visible; the extra request and complexity aren’t worth it.
Gotchas
- Default export:
lazy(() => import('./X'))expects the module to have a default export; uselazy(() => import('./X').then(m => ({ default: m.Named })))for named exports. - Suspense boundary: The boundary must be above the lazy component; put it at the route or layout level so the fallback shows while the chunk loads.