Frontend Performance: Code Splitting and Lazy Loading

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.

Scroll to Top