The bundle we split by route, and the waterfall it created

The bundle was nine hundred kilobytes and everybody agreed it should be split by route. It was, the total transferred went down, and the page got slower — because six chunks that must be fetched in sequence is worse than one chunk that is bigger.

The symptom

the network panel, 4G, /orders:

  index.js          12 KB   0ms → 180ms
  vendor.js        310 KB   180 → 890ms
  router.js         18 KB   890 → 1,050ms
  orders.js         41 KB   1,050 → 1,230ms
  table.js          88 KB   1,230 → 1,480ms
  charts.js        142 KB   1,480 → 1,890ms
  ─────────────────────────────────────────
  first render                    1,940ms

  before the split: one 900 KB file, 1,610ms.

transferred less. rendered later.

Each chunk can only be discovered once the one that imports it has been parsed, so the requests are serial rather than parallel. Six round trips on a connection with a hundred and eighty millisecond latency is a second of nothing but waiting.

Why it happens

A bundler splits where the code says import(), and a route module importing a component that imports a chart library produces a chain. The size report shows six small files and says nothing about the order they have to arrive in.

The fix

Reading the chunk graph rather than the size report

// vite.config.js
import { visualizer } from 'rollup-plugin-visualizer'

export default {
  plugins: [visualizer({ template: 'network' })],
}

// and the number the size report does not give you:
// the longest chain from entry to first render.
//
//   index → vendor → router → orders → table → charts
//   depth 6

Depth is the metric, not size. A hundred kilobytes at depth two beats forty kilobytes at depth five on any connection with real latency, and no default build output reports depth at all.

The shared dependency in four chunks

$ npx vite-bundle-visualizer --json | 
  jq -r '.modules[] | select(.id | test("date-fns")) | .chunk' | sort | uniq -c
   1 orders
   1 invoices
   1 reports
   1 charts

# 38 KB of date formatting, four times, because four
# route chunks each imported it and none of them was
# a common ancestor.

Rollup only hoists a module into a shared chunk when it is imported by several entry points; a module imported by four dynamic chunks is duplicated into each. That is correct behaviour and it is not what anybody expects from a “shared” dependency.

manualChunks, aimed at the actual problem

build: {
  rollupOptions: {
    output: {
      manualChunks(id) {
        // the heavy, rarely-used one gets its own chunk
        if (id.includes('node_modules/chart.js')) return 'charts'

        // everything else in node_modules goes together,
        // so it is one request at depth 1
        if (id.includes('node_modules')) return 'vendor'
      },
    },
  },
}
and the larger win, which was not a chunking change:

  date-fns, used for four format calls
  → Intl.DateTimeFormat, which is in the platform
  → 38 KB removed entirely, from four chunks

  chart.js, on two routes
  → kept, and lazily imported at the component rather
    than the route, so it is fetched in PARALLEL with
    the route's data

Removing a dependency beats splitting it, every time, and it is the option that gets skipped because splitting feels like the engineering answer. Four format calls did not justify thirty-eight kilobytes.

Flattening the chain with modulepreload

<!-- generated by the build, from the chunk graph -->
<link rel="modulepreload" href="/assets/vendor-8c1f.js">
<link rel="modulepreload" href="/assets/router-4a7e.js">

<!-- the browser now fetches these in parallel with
     index.js rather than after parsing it. depth 6
     becomes depth 2 for everything preloaded. -->

Vite emits these automatically for static imports of the entry chunk and not for dynamic ones, which is the case that matters. Adding them for the two chunks every route needs collapsed most of the waterfall without changing a line of application code.

Preloading the next likely route, and why we stopped

prefetching on link hover: 40% of prefetches were used.
prefetching by heuristic ("most users go to /orders"):
  61% used on desktop, 22% on mobile.

the mobile number is the problem: 78% of prefetched
bytes on a metered connection were wasted, to save
180ms for the fifth of users who did navigate.

kept: hover prefetch on pointer devices only.
dropped: the heuristic. it was a guess dressed as an
optimisation.

The two splits we reverted

  table.js    88 KB, used on 6 of 8 routes.
              a separate request to save 88 KB for the
              2 routes that do not use it.
              → folded into vendor.

  router.js   18 KB, needed by every route, at depth 3.
              a round trip for 18 KB.
              → folded into the entry chunk.

the rule that came out of it: a chunk under ~50 KB that
is needed by most routes is not worth a request.

Verifying it worked

the network panel, 4G, /orders, after:

  index.js         48 KB   0 → 240ms      (+ router)
  vendor.js       272 KB   0 → 810ms      (preloaded)
  orders.js        41 KB   240 → 420ms
  ─────────────────────────────────────────
  first render                    880ms

  charts.js       142 KB   fetched in parallel with
                           the orders data, arrives
                           before it is needed

  chain depth      6 → 2
  transferred    611 → 503 KB
  p75 first render  1,940ms → 880ms

Both numbers improved, which is the outcome, and the depth is the one that did the work — the transferred bytes moved by eighteen per cent and the render time by fifty-five. Measuring on a throttled connection is not optional here; on a fast one the original waterfall was invisible.

What this costs

A build configuration that must be re-measured when the application changes. The manualChunks function encodes a decision about which dependency is heavy and rarely used, and that stops being true the moment somebody adds a chart to the dashboard.

It is also a configuration nobody will read before adding a route. The chunk graph is not visible in the source, so a new route importing something enormous produces a regression that no test catches — a build-time assertion on maximum chain depth would catch it, and we have not written one.