MODRACXKENNETH D'SILVA

← Archive & Insights

Code Splitting & Route-Based Lazy Loading

A homeware retailer had a 1.42MB JavaScript bundle and an INP of 640ms. Every metric they were watching had improved. This is what fixed it, and the two weeks I wasted first.

By Kenneth D'SilvaReading Time: 25 min readCategory: Performance & Speed

1. The Bundle That Ate The Product Page

A signage retailer emailed me a Lighthouse screenshot in March 2024 with the subject line "we've done everything on the list and it's got worse". They had. Images were AVIF with correct dimensions, critical CSS was inlined, fonts were subset and preloaded, the CDN was configured properly. Largest Contentful Paint on a product page was 1.9 seconds on a throttled 4G profile. Genuinely good.

Interaction to Next Paint was 640 milliseconds. The colour swatches on the product page took most of a second to respond to a tap. Their conversion rate on mobile had dropped four percent over the previous quarter and nobody could explain it, because every metric they were watching had improved.

The main JavaScript bundle was 1.42MB compressed. Uncompressed, 4.6MB. It contained a full date library because someone needed to format a delivery estimate, a charting package that was used only in the account area's order history graph, three separate carousel implementations from three different agency engagements, and a moment-locale directory covering every language on earth for a store that sold exclusively in the UK.

Every single byte of that was downloaded, parsed, compiled and executed before the colour swatch could respond to a tap. On the mid-range Android that most of their mobile traffic actually used, that was 2.8 seconds of main thread work. Not download time — download was fine, they had a good CDN. Main thread work.

This article is about that problem specifically: the JavaScript you ship, how to ship less of it up front, and what code splitting does and does not fix. If you're here for deferring images, iframes and video, that's a different mechanism with different tradeoffs and it lives in the media lazy loading guide. This one is entirely about JavaScript.

2. What A Megabyte Of JavaScript Actually Costs

The number people quote is transfer size, because that's the number the network panel shows first and it's the number that goes in the ticket. It is the least interesting of the three numbers that matter.

Transfer size is what crosses the wire, after Brotli or gzip. Decompressed size is what the parser sees. Execution cost is what the CPU spends turning that source into running code and then running it. For a typical ecommerce bundle these scale roughly 1 : 3.3 : (device-dependent, and the device dependency is brutal).

A 1MB compressed bundle decompresses to something around 3.3MB of JavaScript. On a 2023 MacBook Pro that parses and compiles in maybe 180 milliseconds and you'd never notice. On a Moto G Power — which is roughly the median Android device by traffic share in most Western markets, not a worst case — the same bundle costs somewhere between 1.5 and 3 seconds of main thread time depending on how much of it actually executes at startup.

That ratio is the whole argument. Your laptop is between eight and fifteen times faster than the device your customer is holding. Every performance decision made on a development machine is made with a fifteen-times handicap in your favour, which is why "it feels fine locally" is the single least useful sentence in performance work.

Bundle (compressed)DecompressedDesktop parse + execMid-range Android
170KB~560KB~40ms~380ms
400KB~1.3MB~95ms~900ms
750KB~2.5MB~170ms~1,700ms
1.4MB~4.6MB~310ms~2,900ms

Those Android figures are from real traces on a Moto G Power running Chrome, not from a formula, and they vary by maybe thirty percent depending on how much of the bundle is top-level side-effecting code versus function bodies that are lazily compiled. V8 does not fully compile everything it parses — it does a quick pre-parse and defers full compilation of function bodies until they're called. That helps, and it means a bundle full of unused exports costs less than its size suggests. It does not help nearly as much as not shipping the code.

3. Parse, Compile, Execute: Where The Time Goes

It's worth being precise about the stages, because the fix differs for each.

Download. Network-bound. Fixed by compression, CDN placement, and HTTP/2 or HTTP/3 multiplexing. Usually already solved on any site that has had a performance engagement. Rarely the bottleneck in 2026.

Parse and pre-parse. V8 scans the source, builds an AST for eagerly-needed code, and records the boundaries of function bodies it will compile later. Roughly linear in bytes. This is where "ship less code" is the only lever — you cannot make parsing faster except by parsing less.

Compile. Ignition produces bytecode. For functions invoked immediately at module scope this happens right away. For everything else it's lazy. Later, if a function gets hot, TurboFan optimises it, which costs more CPU up front and pays back on repeated calls.

Execute. Module top-level code runs. This is where framework bootstrap lives, where polyfills install themselves, where analytics libraries attach listeners, and where a badly-written third-party script will spend 400ms doing something you cannot see.

The reason this matters for splitting decisions: moving a module into a lazily-loaded chunk removes its download, parse and compile cost from startup entirely. Moving a module into a chunk that you then eagerly prefetch and execute anyway removes nothing except the appearance of a problem. I have reviewed more than one "code splitting" PR that split a bundle into eleven chunks, all of which were loaded in parallel on first paint, and shipped exactly the same amount of work with a worse waterfall.

4. INP Is The Metric That Punishes This

Interaction to Next Paint replaced First Input Delay as a Core Web Vital in March 2024, and the swap changed which mistakes are expensive.

FID measured only the delay before an event handler started running. If your page was busy for two seconds and the user tapped at 2.1 seconds, FID was zero and you passed. It flattered almost everybody, which is why roughly ninety percent of origins passed it and it told you nothing.

INP measures the full interaction: input delay, plus the handler's own processing time, plus the presentation delay until the next frame paints. It takes roughly the worst interaction of the page visit — technically the 98th percentile once you're past fifty interactions. The threshold is 200ms for "good", 500ms for "needs improvement".

Shipping too much JavaScript hurts INP through three separate channels.

First, long tasks during startup block input. If the main thread is compiling your bundle when the user taps, the tap waits. That's input delay, and it's the part FID also caught.

Second, the handlers themselves are slower in a large application. More components subscribed to more stores, more effects, deeper React trees to reconcile. A colour swatch click that touches a context provider near the root of a 3,000-node tree costs real time.

Third — and this is the one teams miss — presentation delay. After your handler runs, the browser needs to do style, layout, paint and composite. A heavy DOM with a lot of framework-managed nodes makes that frame expensive. Your handler took 12ms and INP is 340ms, and all of the difference is in the frame.

// Log every interaction's breakdown, not just the total.
// Run this on a real device, not your laptop.
new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.interactionId === 0) continue; // not a discrete interaction
    const inputDelay = entry.processingStart - entry.startTime;
    const processing = entry.processingEnd - entry.processingStart;
    const presentation = entry.startTime + entry.duration - entry.processingEnd;
    console.log(entry.name, {
      total: Math.round(entry.duration),
      inputDelay: Math.round(inputDelay),
      processing: Math.round(processing),
      presentation: Math.round(presentation)
    });
  }
}).observe({ type: 'event', durationThreshold: 40, buffered: true });

That breakdown decides your strategy. High input delay means startup work — split the bundle. High processing means your handler is doing too much — break it up or move work off the critical path. High presentation delay means the DOM or the CSS is expensive, and no amount of code splitting will touch it. The wider Core Web Vitals picture, and how to keep these numbers from regressing after you fix them, is covered in the CWV monitoring guide.

5. Route-Based Splitting Is The First Cut

If you do exactly one thing, do this. Split on routes.

The argument is simple and it holds almost everywhere: a customer landing on a product page does not need the checkout code, the account dashboard, the order history charts, or the wishlist manager. They might need checkout in ninety seconds. They will probably never need the account area at all. Yet the default single-entry build ships all of it in one file.

Route splitting typically takes forty to sixty percent off the initial bundle on a storefront, and it requires no architectural change. It's the highest ratio of benefit to risk available in frontend performance work.

// React Router 6.4+ — lazy() on the route object, so the router
// loads the chunk while it resolves the loader, in parallel.
import { createBrowserRouter } from 'react-router-dom';

const router = createBrowserRouter([
  {
    path: '/',
    element: <RootLayout />,
    children: [
      { index: true, lazy: () => import('./routes/Home') },
      { path: 'product/:handle', lazy: () => import('./routes/Product') },
      { path: 'collection/:handle', lazy: () => import('./routes/Collection') },
      // Checkout is the biggest single route and the least often reached.
      { path: 'checkout/*', lazy: () => import('./routes/Checkout') },
      { path: 'account/*', lazy: () => import('./routes/Account') }
    ]
  }
]);

Note the difference between the router's lazy property and wrapping a component in React.lazy. The route-level version loads the module and its data loader together; the component-level version loads the component, renders a fallback, and only then discovers it needs data. That's a sequential waterfall you get for free by choosing the wrong API.

In Next.js the App Router does route splitting for you and there is nothing to configure — each page.tsx is its own entry. The trap there is different: anything imported into a layout is in every route's graph, and layouts are where analytics wrappers, providers and design-system barrels accumulate. I have seen a Next app where the root layout imported an icon barrel file and pulled 340KB of SVG components into every page in the application.

6. Component-Level Splitting And Where It Stops Paying

Below the route level, the calculus changes. Every dynamic import is a separate network request, a separate cache entry, a separate chance for a loading state to flash. Splitting a 4KB component is not free — it is actively negative once you account for the request overhead and the module registry bookkeeping.

My rule of thumb, which I'll defend but not pretend is scientific: split a component if it is above roughly 30KB compressed and it is not rendered on first paint for the majority of visitors. Both conditions. Either one alone is not enough.

Things that reliably qualify on an ecommerce site:

Rich text editors in the review form. Anything with a WYSIWYG in it is 150KB minimum and is used by a fraction of a percent of visitors. Map components on a store locator. Video players — the player library, not the video. Charting on any dashboard. Date pickers, which are astonishingly large for what they do. Product configurators and 3D viewers. Anything modal: size guides, quick views, the fit finder, the "complete the look" tray.

Things that do not qualify and get split anyway: the header, the cart drawer (customers open it constantly and the flash of an empty drawer is worse than the bytes), the product image gallery, form validation logic, and anything that renders above the fold.

// Load the size guide only when someone actually opens it.
// The import is inside the handler, so the chunk request starts on click.
async function openSizeGuide(productType) {
  const modal = showModalShell();          // instant, from the main bundle
  const { renderSizeGuide } = await import(
    /* webpackChunkName: "size-guide" */
    './components/SizeGuide'
  );
  renderSizeGuide(modal.body, productType);
}

// Better: warm the chunk on hover/focus so the click feels instant.
sizeGuideButton.addEventListener('pointerenter', () => {
  import('./components/SizeGuide');        // fills the module cache
}, { once: true, passive: true });

That second pattern — prefetch on intent, load on action — is the one that makes component splitting feel free to users. The gap between a pointer entering a button and a click landing is typically 200 to 400ms on desktop, which is enough to fetch a 40KB chunk over any reasonable connection. On touch devices you get touchstart instead, which buys you maybe 90ms, and that's still better than nothing.

7. Dynamic import() In Practice

import() returns a promise for the module namespace object. That's the whole API. Everything else is bundler convention layered on top.

The critical constraint, and the one that produces the most confused bug reports: the specifier must be statically analysable enough for the bundler to know what to emit. A fully dynamic string is not.

// This does not work. The bundler cannot know what to build.
const mod = await import(userSuppliedPath);

// This works, and produces one chunk per file matching the pattern.
// Webpack builds a context module; Vite globs at build time.
const mod = await import(`./widgets/${widgetName}.js`);

// This is what I actually ship — explicit, greppable, no magic.
const WIDGETS = {
  reviews:   () => import('./widgets/Reviews.js'),
  sizeGuide: () => import('./widgets/SizeGuide.js'),
  configurator: () => import('./widgets/Configurator.js')
};

async function mount(name, el) {
  const loader = WIDGETS[name];
  if (!loader) return;                     // unknown widget, fail quietly
  const { default: Widget } = await loader();
  new Widget(el).render();
}

I prefer the explicit map for three reasons. The template-literal form produces a chunk for every file in the directory whether you use it or not, which quietly bloats your build output. It makes dead code impossible to detect, because every file in the folder is reachable. And when someone deletes a widget file, the explicit map breaks at build time rather than at runtime in production on a Friday.

The other thing to know: import() results are cached by the module system. Calling it twice returns the same promise-resolved namespace and does not re-fetch. That's what makes the hover-prefetch pattern above work — the click's import() is a cache hit.

Handling failure

Dynamic imports fail. A chunk request can 404 because you deployed while a user had the page open and the hashed filename changed. It can fail on a flaky mobile connection. It can fail because a corporate proxy decided your CDN was suspicious.

// Retry with backoff, then fall back to a full page load.
// The reload is the important part: after a deploy, the old chunk
// names are gone and only a fresh document will have the new ones.
async function loadChunk(loader, attempts = 3) {
  for (let i = 0; i < attempts; i++) {
    try {
      return await loader();
    } catch (err) {
      if (i === attempts - 1) {
        // Guard against a reload loop on a genuinely broken deploy.
        const key = 'chunk-reload-at';
        const last = Number(sessionStorage.getItem(key) || 0);
        if (Date.now() - last > 10000) {
          sessionStorage.setItem(key, String(Date.now()));
          location.reload();
        }
        throw err;
      }
      await new Promise(r => setTimeout(r, 250 * 2 ** i));
    }
  }
}

The deploy case is the common one and almost nobody handles it. If your build hashes filenames and your CDN purges old assets, every user with an open tab has a broken application the moment you ship. Keeping the previous two builds' assets available for a week is the cheaper half of the fix; the reload fallback is the other half.

8. React.lazy, Suspense, And The Fallback Trap

React.lazy is a thin wrapper that turns a dynamic import into a component that throws a promise, which Suspense catches. It works well and it has two failure modes that show up in production and not in development.

The first is fallback placement. If your Suspense boundary is at the application root, a lazily-loaded modal will unmount the entire page while its chunk loads. The user taps "size guide", the page goes blank for 300ms, and comes back. That is worse than shipping the code.

// Wrong: the boundary is above the layout, so the whole page blanks.
<Suspense fallback={<Spinner />}>
  <ProductPage />
  {open && <SizeGuide />}
</Suspense>

// Right: the boundary wraps only the lazy subtree, and the fallback
// occupies the same box the real content will, so nothing shifts.
<ProductPage />
{open && (
  <Suspense fallback={<div className="size-guide-skeleton" />}>
    <SizeGuide />
  </Suspense>
)}

The second is layout shift. A fallback of different dimensions to the loaded component produces a CLS event, and CLS is measured for the whole page lifecycle, not just load. A spinner that's 40px tall replaced by a 600px modal body will show up in your field data as a mysterious late-session shift that nobody can reproduce. Reserve the space.

React 18's startTransition matters here too. Wrapping a state update that triggers a lazy boundary in a transition tells React to keep showing the old UI rather than falling back, which removes the flash entirely when the chunk loads quickly.

import { startTransition, useState, Suspense, lazy } from 'react';

const Configurator = lazy(() => import('./Configurator'));

function ProductActions() {
  const [showConfig, setShowConfig] = useState(false);
  return (
    <>
      <button
        onPointerEnter={() => { import('./Configurator'); }}
        onClick={() => startTransition(() => setShowConfig(true))}
      >
        Customise
      </button>
      {showConfig && (
        <Suspense fallback={<div className="config-skeleton" />}>
          <Configurator />
        </Suspense>
      )}
    </>
  );
}

9. Vue, Svelte, And Framework-Agnostic Splitting

None of this is React-specific, and the non-React ecosystems are in some ways better at it because they ship less runtime to begin with.

Vue's defineAsyncComponent takes a loader function and options that React's lazy makes you build yourself — a delay before showing the loading component, a timeout, an error component. The delay option is genuinely useful: setting it to 200ms means a chunk that loads in 80ms never shows a spinner at all, which removes most of the flash problem by default.

import { defineAsyncComponent } from 'vue';

const SizeGuide = defineAsyncComponent({
  loader: () => import('./SizeGuide.vue'),
  loadingComponent: SkeletonBox,
  delay: 200,        // don't show the skeleton for fast loads
  errorComponent: LoadError,
  timeout: 8000
});

SvelteKit splits by route automatically and its per-component runtime cost is close to zero because the compiler emits imperative DOM code rather than shipping a reconciler. A Svelte storefront with the same feature set as a React one typically ships 60-70% less framework code. That's not an argument for a rewrite — rewrites lose more than they win — but it is worth knowing when someone asks why the numbers are what they are.

For a site with no framework at all, or a Magento storefront using Hyvä where Alpine handles interactivity, splitting is still worth doing on the handful of heavy widgets. The mechanism is the same import(); the difference is you're doing the mounting yourself rather than letting a framework do it.

// Vanilla: mount heavy widgets only when they scroll near the viewport.
// IntersectionObserver with rootMargin gives you a head start.
const io = new IntersectionObserver((entries) => {
  for (const entry of entries) {
    if (!entry.isIntersecting) continue;
    io.unobserve(entry.target);
    const name = entry.target.dataset.widget;
    mount(name, entry.target);   // the explicit-map loader from earlier
  }
}, { rootMargin: '400px 0px' });   // start loading 400px before it's visible

document.querySelectorAll('[data-widget]').forEach(el => io.observe(el));

10. Reading A Bundle Analysis Without Lying To Yourself

Every bundler has a treemap visualiser and every team looks at it once, says "huh, lodash", and closes the tab. The tool is fine. The way people read it is the problem.

Three specific mistakes.

Reading parsed size instead of gzip size. The default view in webpack-bundle-analyzer is parsed size, which overstates the network cost of highly repetitive code. A 200KB parsed file of generated API client code might be 18KB over the wire. Switch to the gzip view for network decisions and stay on parsed for CPU decisions, because parse cost tracks the decompressed bytes.

Looking only at the biggest box. The biggest box is usually your framework and you're not removing it. The interesting finding is almost always a mid-sized box that has no business being in the initial chunk at all — a PDF generator, an Excel export library, a country-code phone validator with every country's rules.

Analysing one build. A single snapshot tells you what's there. A diff between two builds tells you what someone just added, which is the actionable version.

# Vite: emit a treemap and keep it out of the deploy
npx vite-bundle-visualizer -o .stats/bundle.html

# Webpack: emit stats JSON in CI, compare against the last build
npx webpack --profile --json=.stats/current.json

# The number that belongs in your CI output — total initial JS, gzipped
find dist/assets -name '*.js' -exec gzip -c {} \; | wc -c

# Per-file, sorted, so a regression has an obvious owner
for f in dist/assets/*.js; do
  printf '%8s  %s\n' "$(gzip -c "$f" | wc -c)" "$(basename "$f")"
done | sort -rn | head -20

Put a budget in CI and fail the build when it's exceeded. Not a warning — a failure. Warnings are read for two weeks and then filtered into a folder.

// vite.config.js — a build that gets slower is a build that fails.
export default {
  build: {
    rollupOptions: {
      output: {
        // Keep the framework in its own long-lived chunk so a product
        // code change doesn't invalidate 140KB of React for everyone.
        manualChunks(id) {
          if (id.includes('node_modules/react') ||
              id.includes('node_modules/scheduler')) return 'react';
          if (id.includes('node_modules/@sentry')) return 'observability';
        }
      }
    },
    chunkSizeWarningLimit: 250   // kB, uncompressed, per chunk
  }
};

11. Code You Ship Twice, And Code You Never Run

Duplicate dependencies

This is the finding that surprises teams most often, and it's worth checking before you do any splitting work at all, because it's usually a bigger number than anything splitting will save you.

Your bundle contains the same library more than once, at different versions, because two of your dependencies each pinned a different major. npm and yarn will happily install both, nested, and your bundler will happily include both. I have found three copies of a virtual-list library, two copies of a date library at 4.x and 5.x, and — my personal favourite — an entire second copy of React because a component library had it as a regular dependency instead of a peer.

# What's installed more than once
npm ls --all 2>/dev/null | grep -E 'deduped|UNMET' | head

# The direct question: how many copies of this package exist?
npm ls date-fns
npm ls react

# pnpm is stricter and will tell you plainly
pnpm why react

The fixes, in order of preference: bump the dependency that's holding the old version; add an overrides block to force a single version and test carefully; or alias the duplicate in your bundler resolution as a last resort, which works but hides the problem from the next person.

{
  "overrides": {
    "date-fns": "3.6.0",
    "react": "18.3.1",
    "react-dom": "18.3.1"
  }
}

On the signage retailer's site, deduplicating dependencies removed 210KB compressed before we split a single route. It took an afternoon and involved no architectural decisions at all. Always check this first.

Tree shaking, and why it silently does nothing

Tree shaking removes exports nothing imports. It works via static analysis of ES module syntax, and there are four common reasons it produces no benefit on a real codebase.

The dependency ships CommonJS. require() is dynamic by specification, so the bundler cannot prove an export is unused. Any package without an "module" or modern "exports" field is shipped whole. Check the package's package.json before you assume.

Side effects are not declared. If your own package.json lacks "sideEffects": false, the bundler assumes importing any module might do something observable and keeps it. Adding that one field to an internal component library is sometimes worth 50KB on its own. Be careful: if you have CSS imports or polyfills, use the array form rather than a blanket false.

{
  "name": "@shop/ui",
  "sideEffects": ["*.css", "./src/polyfills.js"]
}

Barrel files. An index.ts that re-exports two hundred components means importing one component pulls the whole barrel into the module graph. Bundlers can often shake it back out, but the analysis is expensive and it fails whenever any module in the barrel has side effects. It also destroys your build times. Import from the file, not the barrel.

// Pulls the entire icon set into the graph
import { ChevronDown } from '@shop/ui';

// Pulls one file
import { ChevronDown } from '@shop/ui/icons/ChevronDown';

Namespace imports. import * as utils defeats the analysis in most bundlers because any property access could be dynamic. Named imports only.

12. Vendor Chunks And Long-Term Caching

The default splitChunks configuration in Webpack, and Rollup's default chunking in Vite, both produce something reasonable and neither produces something optimal for a storefront with returning customers.

The goal is that a typical deploy — which changes application code and nothing else — invalidates as few bytes as possible for people who already have your assets cached. If your framework lives in the same file as your product page component, every deploy makes every returning customer re-download React.

The counter-goal is that too many chunks costs requests and, more importantly, costs a compression ratio. Brotli on a 200KB file achieves a better ratio than Brotli on ten 20KB files, because the dictionary has more to work with. Somewhere between three and six initial chunks is usually right.

ChunkContentsChangesCache header
frameworkReact, scheduler, router coreEvery few months1 year, immutable
vendorOther node_modules used on first paintMonthly1 year, immutable
appShared application codeEvery deploy1 year, immutable
route-*Per-route code, loaded on demandPer deploy, per route1 year, immutable
index.htmlChunk referencesEvery deployno-cache

Everything hashed gets a year and immutable; the document gets none. Get this backwards and you either serve stale bundles or you serve nothing from cache. The most common misconfiguration I find is an over-broad CDN rule that caches HTML alongside assets, which produces a store that appears to have deployed successfully and hasn't.

13. Hydration Is The Bill You Pay For Server Rendering

Server rendering solves LCP and it does not solve INP. This is the single most misunderstood thing in modern frontend performance and it costs teams entire quarters.

Here is what happens on an SSR page. The server sends complete HTML. The browser paints it — fast, often under a second, and your LCP is excellent. Then the JavaScript bundle downloads. Then the framework walks the entire server-rendered DOM, builds its virtual representation, matches it against the markup, and attaches event listeners to everything. Only then does anything respond to a click.

Between paint and the end of hydration you have a page that looks completely finished and does nothing. It's often called the uncanny valley, and it is measurably worse for user experience than a page that looks unfinished, because a page that looks ready invites interaction that then fails.

Hydration cost scales with component count and with the total JavaScript you shipped, not with what's visible. A product page with a 2,400-node server-rendered tree takes 400 to 900ms to hydrate on mid-range Android. During that window every tap queues.

// Measure the gap between "looks done" and "actually works".
// Put the mark at the end of your root hydrate/mount callback.
performance.mark('hydration-end');

const fcp = performance.getEntriesByName('first-contentful-paint')[0];
const [hyd] = performance.getEntriesByName('hydration-end');
console.log('dead time:', Math.round(hyd.startTime - fcp.startTime), 'ms');

// Long tasks during that window are what block the user's first tap
new PerformanceObserver((l) => {
  for (const t of l.getEntries()) {
    if (t.duration > 50) console.warn('long task', Math.round(t.duration), 'ms');
  }
}).observe({ type: 'longtask', buffered: true });

The fixes are not code splitting. Splitting reduces the bytes but the components that are on the page still hydrate. What actually reduces hydration cost is hydrating less of the page.

14. Islands, Partial Hydration, And Server Components

The architectural answer to hydration cost is to stop hydrating things that don't need it. Three approaches, all shipping in production today.

Islands. Astro's model. The page is static HTML by default and you opt individual components into interactivity, each becoming its own independent hydration root with its own tiny bundle. A product page might have three islands — the gallery, the variant picker, the cart button — totalling 40KB, with the other 90% of the page never touched by JavaScript.

<!-- Astro: each directive is a different loading strategy -->
<ProductGallery client:load images={images} />
<VariantPicker client:idle variants={variants} />
<ReviewsWidget client:visible productId={id} />
<SizeGuide client:media="(min-width: 768px)" />

client:visible is the one that pays on a storefront. Reviews are almost always below the fold and almost always heavy.

React Server Components. Components that run only on the server and ship no JavaScript at all. The boundary is "use client", and the discipline is pushing that directive as far down the tree as you can. A common and expensive mistake is marking a layout as a client component because one button inside it needs state, which makes everything below it client code.

// Bad: the whole page becomes client code for one button.
'use client';
export default function ProductPage({ product }) { /* ... */ }

// Good: the page stays on the server; only the button ships JS.
export default function ProductPage({ product }) {
  return (
    <article>
      <ProductDescription html={product.descriptionHtml} />  {/* server */}
      <SpecTable specs={product.specs} />                     {/* server */}
      <AddToCartButton variantId={product.defaultVariantId} /> {/* client */}
    </article>
  );
}

Resumability. Qwik's approach: serialise the application state into the HTML and attach nothing until an event fires, at which point only the code for that handler loads. It genuinely eliminates hydration. It is also a small ecosystem and I'd be cautious about betting a commercial storefront on it unless the team is comfortable being early. That's a judgement about risk, not about the technology, which is sound.

If you're weighing these against each other for a storefront rebuild, the architectural tradeoffs sit alongside the ones discussed in the headless commerce guide — the decision is rarely just about JavaScript weight.

15. Prefetching Chunks Without Wrecking The Network

Splitting introduces a new cost: the first time a user navigates to a split route, they wait for a network request that used to be free. Prefetching pays that cost during idle time instead.

The naive version — prefetch every route on load — is worse than not splitting, because you've downloaded everything anyway and added a hundred requests. The useful version prefetches based on evidence of intent.

// Prefetch a route's chunk when its link enters the viewport,
// but only on connections where that's a reasonable thing to do.
const conn = navigator.connection;
const stingy = conn && (conn.saveData || /2g/.test(conn.effectiveType));

if (!stingy) {
  const io = new IntersectionObserver((entries) => {
    for (const e of entries) {
      if (!e.isIntersecting) continue;
      io.unobserve(e.target);
      const link = document.createElement('link');
      link.rel = 'prefetch';
      link.as = 'script';
      link.href = e.target.dataset.chunk;
      document.head.appendChild(link);
    }
  }, { rootMargin: '200px' });

  document.querySelectorAll('a[data-chunk]').forEach(a => io.observe(a));
}

Respect saveData. Someone who has explicitly asked their browser to use less data has told you something, and speculatively downloading a megabyte of route chunks against their wishes is rude and, in some markets, expensive for them.

Speculation Rules are the modern alternative and they're better for full navigations because the browser handles the eagerness heuristics for you. The interaction between speculation rules, prefetch and the rest of the hint family is covered in the resource hints guide.

16. Waterfalls: The Failure Mode Nobody Measures

This is the most expensive mistake in code splitting and it is invisible in a bundle analyser, because the bundle analyser shows you sizes and the problem is sequencing.

A request waterfall happens when chunk A must download and execute before the browser learns it needs chunk B. Three levels deep on a 300ms round trip is 900ms of nothing, and none of that time appears in any size-based metric.

The classic shape: the entry bundle loads the router, the router loads the route chunk, the route chunk imports a shared component chunk, the shared component chunk imports a vendor chunk it needs. Four sequential requests.

// Waterfall: data fetch cannot start until the component chunk arrives.
const Product = lazy(() => import('./Product'));
// ...Product's useEffect fetches /api/product/:id after it mounts.

// Flat: kick both off at once, then render when both settle.
function ProductRoute({ handle }) {
  const [mod, data] = use(
    Promise.all([
      import('./Product'),
      fetch(`/api/product/${handle}`).then(r => r.json())
    ])
  );
  const Product = mod.default;
  return <Product data={data} />;
}

The general principle: start every request you know you'll need as early as you can prove you need it. Route loaders in React Router and Remix exist precisely for this — the loader runs in parallel with the component chunk fetch rather than after it.

To find waterfalls, open DevTools' network panel, filter to JS, and look at the left edge of each bar. If the bars form a staircase rather than a block, you have a waterfall. This takes about fifteen seconds and I would guess two thirds of split applications have at least one.

17. A Worked Example: The Homeware Retailer

Back to the site from the opening. Next.js Pages Router at the time, headless Shopify backend, roughly 8,000 SKUs, 71% mobile traffic.

Starting point. Initial JS 1.42MB compressed. INP at the 75th percentile, 640ms. LCP 1.9s, which was fine. Total Blocking Time in the lab, 3.2 seconds.

Week one: deduplication and dead code. Two copies of date-fns, an entire charting library reachable from a shared utility barrel, and 260KB of moment locales that a webpack ContextReplacementPlugin should have been trimming for four years. Removed 340KB compressed. INP dropped to 510ms. No architectural change, no risk, and by a distance the best return of the whole engagement.

Week two: route splitting. Checkout, account, and the store locator moved to dynamic imports. Another 280KB off the initial payload. INP to 420ms. Here we hit our first regression: the cart drawer had been split along with checkout, because it imported a shared checkout utility module. Opening the cart now took 400ms and showed a spinner. We moved the shared utility, the drawer came back into the main bundle, and I've been suspicious of shared-module boundaries ever since.

Week three: component splitting. Reviews widget, size guide, and the 3D room viewer went behind interaction and intersection triggers. 190KB. INP to 340ms. Still not good.

Week four: the actual problem. INP was stuck because hydration was the cost, not download. The page hydrated a 2,600-node tree on every load. We converted the product description, the specification table, the breadcrumbs and the footer to non-interactive server-rendered markup with no client component in their subtree, which meant migrating to the App Router for those routes. Hydration cost fell from 780ms to 240ms on the reference device. INP landed at 180ms.

Where it ended. Initial JS 610KB compressed, INP 180ms at p75, LCP 1.7s. Mobile conversion recovered the four percent over the following six weeks and then some, though I'd be lying if I claimed all of that was attributable — they also changed the delivery messaging in the same period.

What I got wrong. I spent the first two weeks on bundle size because bundle size is measurable and satisfying to reduce. The actual bottleneck was hydration, and I could have found that in an hour with the long-task observer above if I'd looked at the INP breakdown before touching the build config. The download cost was real and worth fixing, but it was maybe forty percent of the problem and I treated it as if it were all of it. Measure the interaction breakdown first. It tells you which of these three chapters you're actually in.

18. What Goes Wrong

Splitting things that were already tiny. A 3KB component behind a dynamic import costs a request, a round trip and a loading state to save 3KB. Below about 20KB compressed, don't.

Splitting above-the-fold components. If it renders on first paint, it needs to be in the initial payload. Splitting it just moves the cost from parallel to sequential and adds a layout shift.

Loading states that shift layout. Every fallback should occupy the same box as the content it replaces. This is a CLS problem masquerading as a splitting problem, and it shows up in field data weeks later.

Over-splitting into a request storm. I've seen a build produce 180 chunks for a five-route application. HTTP/2 multiplexing makes many requests cheaper, not free — there's still per-request overhead, still worse compression, and still a browser-side scheduling cost.

Assuming the framework's defaults are tuned for you. They're tuned for the median project, which is not a storefront with returning customers and a mobile-heavy traffic mix.

Forgetting that third-party scripts don't care about your splitting. You can halve your bundle and still have a 900ms main thread block from a tag manager loading four vendors. Audit those first; they're frequently larger than everything you wrote.

Not handling chunk load failures. Covered above, and it is the difference between a deploy being invisible and a deploy generating support tickets.

19. Questions That Come Up

"How small should the initial bundle be?" I aim for under 200KB compressed of JavaScript on a first load, and I treat 350KB as the point where I'd refuse to ship a new feature without removing something. Those numbers come from working backwards: 200KB compressed is roughly 650KB parsed, which is roughly 500ms of main thread work on the device your median mobile customer is holding. That's a budget you can spend, not a rule handed down.

"Does code splitting help SEO?" Indirectly and modestly. Googlebot renders with a modern Chromium and a generous timeout, so a heavy bundle rarely stops indexing outright. But Core Web Vitals are a ranking signal, INP is one of them, and INP is the metric a heavy bundle destroys. The mechanism is the vitals, not the crawl.

"We split everything and nothing improved." Almost always one of three things: you split it but still load it all on first paint; the bottleneck was hydration or third-party scripts rather than your own bundle; or you introduced a waterfall that ate the gain. Check the network panel for a staircase and check the long-task timeline for who owns the blocking work.

"Should I split CSS as well?" Yes, and it's a different problem with a different answer. Route-level CSS splitting is straightforward in Vite and Next. But CSS is render-blocking in a way JavaScript needn't be, so the priority is getting critical styles inline and everything else deferred — see the critical CSS guide for the mechanics.

"Is a micro-frontend architecture a code splitting strategy?" No, and conflating them causes real damage. Module Federation solves an organisational problem — independent teams deploying independently. It costs you shared-dependency duplication, a runtime negotiation step, and a whole class of version-skew bugs. If you have one team, you want code splitting, not micro-frontends. The tradeoffs are laid out properly in the micro-frontend piece.

"What about lazy-loading below-the-fold images?" Different mechanism entirely — loading="lazy" is a browser-native attribute with no JavaScript involved, and the failure modes are about layout shift and LCP rather than main thread time.

"Our framework does automatic splitting. Are we done?" You've got route splitting for free, which is the biggest single win. You have not got duplicate dependency removal, barrel file discipline, third-party script control, or hydration reduction. Automatic splitting handles maybe half of what's on the table.

20. What I'd Do First

In order, on a site I'd never seen before.

Run the INP breakdown observer from earlier on a real mid-range Android for ten minutes of normal browsing. Find out whether your problem is input delay, processing, or presentation. Everything after this depends on that answer, and it takes twenty minutes.

Then check for duplicate dependencies. npm ls on your five largest packages. This is free, it takes an afternoon to fix, and on more than half the codebases I've audited it's worth more than any splitting work.

Then get a gzipped size for your initial JavaScript payload and put it in CI as a hard failure. You cannot manage what nobody is watching, and bundle size regresses by default — every feature adds and nothing removes.

Then split routes. Checkout and account first, because they're large and rarely on the entry path. Measure after each one rather than doing all of them and hoping.

Then look at the network waterfall for a staircase. Fix the sequencing before you split anything further, or you'll be adding steps to a staircase you haven't noticed.

Then, and only then, consider the architectural work — islands, server components, moving interactivity boundaries down the tree. It's the highest-value change available and it's also the one that costs weeks rather than days, so it needs to be the thing you do when you've established that the cheap options are exhausted.

One habit worth building regardless: whenever someone adds a dependency, ask what it costs in compressed bytes and what it replaces. Not to block it — most of them are fine — but because the discipline of asking is what stops a bundle growing to 1.4MB one reasonable decision at a time. Nobody ever decided to ship a megabyte of JavaScript. It accumulates from forty sensible choices, each of which was individually defensible, and the only defence is a number somebody watches.

Suggested & Related Reading

Explore related engineering guides from Kenneth D'Silva: