Code Splitting & Tree Shaking
โฑ๏ธ ~3-minute bite ยท solve the sandbox to master
5-Year-Old Metaphor
โ The physical, real-world picture. No jargon.๐งณ Pack for your trip as you go โ instead of one giant suitcase (all JS upfront), ship boxes to your destination as you need them.
The airport security analogy
The initial page load is like airport security โ the less you carry, the faster you get through. A 180KB main bundle clears security quickly. An 800KB monolithic bundle makes every user wait while the entire app downloads, parses, and executes โ even the pages they'll never visit.
Monolithic bundle
All pages, all features, all libraries in main.js. User at /home downloads the checkout page's payment processor code. 800KB parsed before anything renders.
Split bundle
180KB main bundle โ renders the home page. Checkout code lives in checkout.js โ downloads only when user navigates to /checkout. Home renders in 2x less time.
Code splitting vs tree shaking โ what's the difference?
Code splitting
- โข Divides code into multiple chunks
- โข Chunks loaded on demand at runtime
- โข Reduces initial JS parsed
- โข Dynamic import() triggers it
- โข The code IS in your bundle โ just deferred
Tree shaking
- โข Eliminates unused exports
- โข Happens at build time โ static analysis
- โข Reduces total bundle size
- โข Requires ESM (import/export)
- โข The dead code is NEVER in the bundle
How React.lazy works under the hood
| 1 | // React.lazy wraps a dynamic import |
| 2 | const MyComponent = React.lazy(() => import('./MyComponent')); |
| 3 | // โ This is equivalent to: |
| 4 | ย |
| 5 | let loadedModule = null; |
| 6 | let loadingPromise = null; |
| 7 | ย |
| 8 | function MyComponent(props) { |
| 9 | if (!loadedModule) { |
| 10 | if (!loadingPromise) { |
| 11 | loadingPromise = import('./MyComponent'); |
| 12 | } |
| 13 | throw loadingPromise; // Suspense catches this! |
| 14 | } |
| 15 | return loadedModule.default(props); |
| 16 | } |
| 17 | // When Suspense catches the thrown Promise, it shows the fallback. |
| 18 | // When the Promise resolves, React retries rendering MyComponent. |
Interactive Sandbox
โ Move something, see it react instantly.Splitting technique
Before
800KBAfter
700KBCode example
| 1 | // React Router v6 with lazy() |
| 2 | import { lazy, Suspense } from 'react'; |
| 3 | import { Routes, Route } from 'react-router-dom'; |
| 4 | ย |
| 5 | const Home = lazy(() => import('./pages/Home')); |
| 6 | const Product = lazy(() => import('./pages/Product')); |
| 7 | const Checkout = lazy(() => import('./pages/Checkout')); |
| 8 | ย |
| 9 | function App() { |
| 10 | return ( |
| 11 | <Suspense fallback={<PageSkeleton />}> |
| 12 | <Routes> |
| 13 | <Route path="/" element={<Home />} /> |
| 14 | <Route path="/product/:id" element={<Product />} /> |
| 15 | <Route path="/checkout" element={<Checkout />} /> |
| 16 | </Routes> |
| 17 | </Suspense> |
| 18 | ); |
| 19 | } |
| 20 | // Each page is a separate chunk โ only loaded on navigation |
Challenge
Explore all 5 code splitting patterns. Focus on the before/after bundle charts โ how much does each technique reduce the initial load? Which technique applies at build time vs runtime?
Why Should I Care?
โ The exact interview question + the bug it kills.Interview questions
Q: What is the difference between code splitting and tree shaking?
Tree shaking removes code that's NEVER used โ it's eliminated at build time and never shipped. Code splitting defers code that's not needed on initial load โ it IS shipped, just as a separate chunk loaded later. They complement each other: tree shaking reduces total bundle size, code splitting defers the remaining code. Both together give the smallest possible initial parse time.
Q: When does lazy loading hurt performance?
When it creates a network waterfall: route loads โ route JS downloads โ component mounts โ data fetch starts. The user sees three sequential waits instead of one. Solution: preload the next route's chunk while the user is still on the current page. Use <link rel="modulepreload"> or the router's built-in prefetching (React Router's <Link prefetch>, Next.js automatic prefetching).
Bug: Declaring React.lazy inside a component
| 1 | // โ Declared inside component โ new lazy() on every render |
| 2 | function Dashboard() { |
| 3 | const Chart = React.lazy(() => import('./Chart')); // BAD |
| 4 | return <Chart />; |
| 5 | } |
| 6 | ย |
| 7 | // โ Declared at module level โ created once |
| 8 | const Chart = React.lazy(() => import('./Chart')); |
| 9 | ย |
| 10 | function Dashboard() { |
| 11 | return ( |
| 12 | <Suspense fallback={<Spinner />}> |
| 13 | <Chart /> |
| 14 | </Suspense> |
| 15 | ); |
| 16 | } |
The Deep Dive
โ Spec refs, engine internals, the minutiae.Preloading chunks for zero-latency navigation
| 1 | // 1. rel="modulepreload" in HTML |
| 2 | <link rel="modulepreload" href="/chunks/checkout.js" /> |
| 3 | ย |
| 4 | // 2. Programmatic preload on hover |
| 5 | function ProductPage() { |
| 6 | const preloadCheckout = () => { |
| 7 | import('./CheckoutPage'); // triggers download, not render |
| 8 | }; |
| 9 | return ( |
| 10 | <button |
| 11 | onMouseEnter={preloadCheckout} // preload on hover |
| 12 | onClick={() => navigate('/checkout')} |
| 13 | > |
| 14 | Buy Now |
| 15 | </button> |
| 16 | ); |
| 17 | } |
| 18 | ย |
| 19 | // 3. Next.js router.prefetch() |
| 20 | const router = useRouter(); |
| 21 | router.prefetch('/checkout'); // preloads the route bundle |
Named chunks for long-term caching
| 1 | // Without chunk name: chunk-abc123.js (hash changes every build) |
| 2 | import('./HeavyComponent'); |
| 3 | ย |
| 4 | // With chunk name: chart-library.js (stable name โ better caching) |
| 5 | import(/* webpackChunkName: "chart-library" */ './ChartLibrary'); |
| 6 | ย |
| 7 | // Vite: use rollupOptions for manual chunks |
| 8 | // vite.config.ts |
| 9 | build: { |
| 10 | rollupOptions: { |
| 11 | output: { |
| 12 | manualChunks: { |
| 13 | 'react-vendor': ['react', 'react-dom'], |
| 14 | 'chart': ['recharts'], |
| 15 | 'editor': ['@tiptap/react', '@tiptap/starter-kit'], |
| 16 | }, |
| 17 | }, |
| 18 | }, |
| 19 | } |
Interview Questions
โ Real questions from real interviews โ with answers.Tree shaking eliminates dead code at build time; code splitting defers live code into separate chunks loaded on demand at runtime.
React.lazy wraps a dynamic import(); when the component renders before the chunk loads, it throws the Promise โ Suspense catches it and shows the fallback until the Promise resolves.
Each render creates a new lazy() wrapper and a new import() call, defeating caching and re-triggering the loading state on every render.
Without preloading, route navigation triggers: route chunk download โ mount โ data fetch โ three sequential waits. Preload the chunk while the user is on the previous page.
Tree shaking requires static import/export analysis at build time; CommonJS require() is dynamic and can run any code path at runtime, making static analysis impossible.
Run webpack-bundle-analyzer or rollup-plugin-visualizer, look for unexpectedly large dependencies (moment.js, full icon libraries, duplicate React), then replace or lazy-load them.
Memory Game
โ Quick quiz โ lock the concept in long-term memory.Which package should you use instead of `lodash` to get tree-shaking support?