Code splitting is the most-leverage performance optimization for modern web apps. The wrong bundling strategy ships 5MB of JavaScript on the first page; the right one ships 200KB and lazy-loads the rest. Senior frontend interviews probe whether you understand the patterns and know when to apply each.
Why code splitting
- First page does not need the entire app’s code
- Less code to download = faster Time to Interactive
- Parser/compiler work decreases with smaller bundles
- Better caching: smaller chunks change less often
Levels of splitting
Route-based splitting
Each route is its own chunk. Going to /dashboard does not download /admin code.
React: const Dashboard = lazy(() => import('./Dashboard'))
Next.js, Remix, React Router 7: built-in
Component-level splitting
Heavy components (chart libraries, rich text editors, video players) loaded on demand.
const HeavyEditor = lazy(() => import('./HeavyEditor'));
<Suspense fallback={<Spinner />}>
<HeavyEditor />
</Suspense>
Vendor splitting
Separate vendor (npm packages) from your app code:
- Vendor chunk caches longer (rarely changes)
- App code changes frequently; cache misses are smaller
Tree shaking
Remove unused exports at build time. Requirements:
- ES modules (not CommonJS)
- Side-effect free (mark in package.json)
- Modern bundler (Vite, Rollup, Webpack 4+)
Without tree shaking, importing one function from lodash bundles the entire library.
Dynamic imports
Triggered on user action:
button.addEventListener('click', async () => {
const { showModal } = await import('./modal');
showModal();
});
Modal code only loads when needed.
Preloading and prefetching
Hint the browser to fetch resources before they are needed:
<link rel="preload">: this resource is needed soon (high priority)<link rel="prefetch">: maybe needed (low priority)<link rel="modulepreload">: ES module preload
Use case: user is likely to navigate to /dashboard next; prefetch its bundle while they are on /home.
Critical rendering path
Optimize what blocks first paint:
- Inline critical CSS in HTML
- Defer non-critical CSS
- Async/defer non-critical JS
- Server-render the HTML for above-the-fold content
Bundle analysis
Always run a bundle analyzer:
- vite-bundle-visualizer
- rollup-plugin-visualizer
- webpack-bundle-analyzer
Look for: unexpected libraries, duplicates, oversized dependencies.
The “everything is dynamic” antipattern
Some engineers split everything. The result: many tiny chunks, each adding HTTP overhead. Net slower.
Sweet spot: 5–15 chunks for typical apps. Vendor + app + a few large feature chunks.
Avoiding waterfall loading
Bad pattern: chunk A imports chunk B; loading A triggers loading B; B imports C; etc. Sequential network dependencies.
Counter:
- Use modulepreload for chained imports
- Use HTTP/2 or HTTP/3 (multiplexing)
- Bundle related modules together
Server-side rendering and chunks
SSR + code splitting: hydration must include the same chunks as the server rendered. Frameworks (Next.js, Remix) handle this automatically.
Common antipatterns
- One massive bundle (no splitting)
- Splitting too aggressively (waterfall)
- Not preloading critical chunks
- Cache-busting wrong (vendor chunk re-downloads on every deploy)
- Lazy-loading above-the-fold content
Frequently Asked Questions
How small should chunks be?
50–200KB compressed is a good range. Below 50KB, HTTP overhead dominates. Above 500KB, parsing becomes slow on mobile.
Should I split CSS too?
Yes. Use CSS-in-JS or scoped CSS that ship with their components. Avoid one massive global CSS file.
What about for SEO?
Server-render the first page. Code-split the rest. Crawlers see the rendered HTML; users get fast hydration.