Community JavaScript Snippet
Route-Level Code Splitting With React.lazy
Our initial bundle was 2.1MB. Splitting routes via React.lazy plus Suspense dropped it to 340KB on first paint. Three accordions on how I wire it.
Route-Level Code Splitting With React.lazy
Our initial bundle was 2.1MB. Splitting routes via React.lazy plus Suspense dropped it to 340KB on first paint. Three accordions on how I wire it.
By @meinakamura
January 24, 2026
·
Updated May 18, 2026
835 views
4
4.5 (10)
The shape is what every router-driven split eventually settles on: const Dashboard = React.lazy(() => import('./Dashboard')) and a <Suspense fallback={<Spinner />}> boundary somewhere above the route. The dynamic import() is what tells webpack or Vite to split at that point, so ./Dashboard.js and everything it transitively imports become a separate chunk that the browser only fetches when the user navigates to that route. The default-export requirement is the most-asked gotcha: lazy expects mod.default, so a named export needs a wrapper module that re-exports it as default. The harness above is a sketch of what React does; the production version handles concurrent renders, error boundaries, and cache invalidation, but the call site is the same.
Both approaches work. A single boundary at the app shell is the right default for tools and dashboards where every screen looks roughly alike, because the fallback doubles as a loading state for the whole nav transition. Per-route boundaries pay off when each destination has its own visual shape: the user sees a dashboard skeleton on the way to a dashboard and a settings skeleton on the way to settings, which feels meaningfully faster even when the chunk size is identical. The hybrid I usually ship is a global boundary for cross-cutting transitions plus per-route boundaries for surfaces with heavy first-paint content. The matrix at the bottom is the cheat sheet I paste into PR descriptions when a teammate asks why a particular split was wrapped a certain way.
The win comes from latency overlap. A typical chunk takes 200 to 800ms over a real network, and a typical hover-to-click delay is 100 to 400ms; if I start the fetch on hover, the click usually lands while the chunk is mid-flight or already cached. Webpack and Vite both deduplicate dynamic imports at the runtime level, so calling loadDashboard() in both onMouseEnter and onClick does not double-fetch. The downside is overhead on touch devices that fire mouseenter synthetically and on users who hover-scroll across a sidebar without intending to navigate; for those cases I gate the preload behind a 100ms timer or use IntersectionObserver to preload links only when they are in view. The numbers in the scenario output are the gap I usually close in production: 600ms cold turns into 400ms warm.
