Measuring Real-User Chunk Loading Performance

A size budget in CI answers one question: did this pull request make the bundle bigger? It cannot answer the question that matters to a user, which is whether the code arrived in time. Those diverge more than most teams expect. A chunk that passes every threshold in enforcing performance budgets in CI can still take two seconds to arrive for a user on a congested network three thousand kilometres from the nearest edge, and no amount of lab measurement will surface that.

Field measurement closes the gap. The browser already records precise timings for every chunk request; the work is collecting them, giving each chunk a stable identity across deploys, correlating them with the interactions users actually perform, and doing all of it cheaply enough that the instrumentation does not become the problem it was meant to detect.

The section overview at bundle analysis and performance budgets sets a 150 KB gzipped initial payload as the byte target. This page is about the other half of the equation: how long those bytes — and the deferred chunks that follow them — actually take to arrive.

What the browser already records

Every chunk request produces a PerformanceResourceTiming entry with far more detail than a simple duration. The fields that matter for chunk analysis are transfer size (zero means it came from cache), encoded and decoded body size (the compression ratio), and the phase timings that separate DNS, connection, request, and response.

The distinction between a cache hit and a cold fetch is the single most valuable signal in the whole dataset, because it splits your population into two groups with completely different experiences. A repeat visitor with a warm cache is measuring your evaluation cost; a first-time visitor is measuring your network path. Optimisations that help one frequently do nothing for the other.

Anatomy of a Chunk Request Timing A cold chunk request broken into DNS lookup, connection, request, and response download phases, compared with a cache hit that skips straight to a short read. Cold fetch — transferSize > 0 DNS 28 ms connect 96 ms request 84 ms response download 212 ms 420 ms Cache hit — transferSize = 0 read 11 ms 11 ms — 38× faster Why the split matters Cold population measures your network path → fix with edge, compression, fewer bytes Warm population measures evaluation cost → fix with less code, not faster delivery

Collecting timings without becoming the regression

The collection pattern that holds up in production is passive observation into a memory buffer, plus a single batched transmission when the page is hidden.

// chunk-rum.js — passive collection, one beacon per session
const ASSET_PREFIX = '/assets/';
const SAMPLE_RATE = 0.1;                 // 10% of sessions
const sampled = Math.random() < SAMPLE_RATE;
const timings = [];

if (sampled && 'PerformanceObserver' in window) {
  const observer = new PerformanceObserver((list) => {
    for (const entry of list.getEntries()) {
      if (!entry.name.includes(ASSET_PREFIX) || !entry.name.endsWith('.js')) continue;
      timings.push({
        // Logical name, not the hashed filename — resolved during ingestion.
        file: entry.name.slice(entry.name.lastIndexOf('/') + 1),
        duration: Math.round(entry.duration),
        transferred: entry.transferSize,          // 0 → served from cache
        decoded: entry.decodedBodySize,
        start: Math.round(entry.startTime),
      });
    }
  });
  // buffered:true also picks up chunks that loaded before this script ran.
  observer.observe({ type: 'resource', buffered: true });
}

// One transmission, on page hide — never on unload, which is unreliable.
addEventListener('visibilitychange', () => {
  if (document.visibilityState !== 'hidden' || !timings.length) return;
  navigator.sendBeacon('/rum/chunks', JSON.stringify({
    build: __BUILD_ID__,
    connection: navigator.connection ? navigator.connection.effectiveType : null,
    timings: timings.splice(0),
  }));
});

Three details in that snippet are load-bearing. buffered: true recovers entries recorded before the observer was created, which otherwise silently loses every chunk on the critical path. sendBeacon on visibilitychange survives backgrounding and navigation in a way that a fetch on unload does not. And sampling happens once per session rather than per entry, so a session’s chunk set is either complete or absent — a half-sampled session produces misleading correlations.

Giving chunks a stable identity across deploys

Hashed filenames are the point of deterministic chunk hashing for long-term caching, and they are also what makes field data unreadable if handled naively: every deploy renames every changed chunk, so a dashboard keyed on filenames restarts from zero each release.

Manifest Resolution Keeps a Series Continuous Three releases emit three different hashed filenames for the same logical chunk; ingestion resolves all of them to one name so the metric series survives each deploy. Three releases, three filenames, one series dashboard-4f2a91.js dashboard-9c31de.js dashboard-71b0aa.js manifest per build id one chart, one name Without this step every deploy starts a new series and trends become unreadable

The fix is a build manifest mapping logical names to hashed filenames, resolved during ingestion rather than shipped to the client.

// vite.config.js — Vite 5+: emit the manifest the ingestion pipeline needs
import { defineConfig } from 'vite';

export default defineConfig({
  build: {
    manifest: true,          // writes .vite/manifest.json alongside the assets
    rollupOptions: {
      output: {
        chunkFileNames: 'assets/[name]-[hash].js',   // logical name stays in the filename
      },
    },
  },
});
// webpack.config.js — Webpack 5: the same contract
module.exports = {
  output: {
    filename: 'assets/[name].[contenthash].js',
    chunkFilename: 'assets/[name].[contenthash].js',
  },
  plugins: [
    // Any manifest plugin works; what matters is that the mapping is uploaded
    // with the release and keyed by the same build id the client reports.
    new (require('webpack-manifest-plugin').WebpackManifestPlugin)({
      fileName: 'chunk-manifest.json',
    }),
  ],
};

Upload the manifest as part of the deploy, keyed by build id. Ingestion then resolves dashboard-C8x1lq.js to dashboard before storage, and every chart survives every release.

Correlating chunks with Core Web Vitals

Raw chunk timings are diagnostic; the value comes from tying them to the metrics the business already tracks.

LCP attribution. Record which chunks were still in flight when the Largest Contentful Paint occurred. A render-blocking or hydration-critical chunk that consistently overlaps the LCP interval is a direct cause, not a correlation — and it is usually a chunk that should have been split further or preloaded.

INP attribution. When an interaction produces a slow Interaction to Next Paint, record whether a chunk was fetched or evaluated inside that interaction’s window. An interaction that triggers a lazy chunk load is measuring network latency, not your event handler, and the fix is prefetching rather than optimising the handler.

// vitals-correlation.js — attach in-flight chunk context to each vital
import { onLCP, onINP } from 'web-vitals';

function chunksInWindow(start, end) {
  return performance.getEntriesByType('resource')
    .filter((e) => e.name.endsWith('.js') &&
                   e.startTime < end && e.startTime + e.duration > start)
    .map((e) => e.name.slice(e.name.lastIndexOf('/') + 1));
}

onLCP((metric) => {
  report('lcp', { value: metric.value, chunks: chunksInWindow(0, metric.value) });
});

onINP((metric) => {
  const entry = metric.entries[metric.entries.length - 1];
  report('inp', {
    value: metric.value,
    // Chunks loading during the interaction are the usual cause of a slow INP
    // on a route that was otherwise already interactive.
    chunks: chunksInWindow(entry.startTime, entry.startTime + metric.value),
  });
});

This correlation is what turns “our INP regressed” into “our INP regressed because the filter panel’s chunk is fetched on first click and takes 380 ms at the 75th percentile” — a statement that names both the cause and the fix, which in that example is the hover prefetching described in prefetch and preload strategies for critical routes.

Quantified impact

  • Lab-to-field gap: typically 3–8× on chunk fetch duration. A chunk that resolves in 60 ms locally routinely takes 300–500 ms at the field 75th percentile, and considerably more at the 95th.
  • Cache hit rate: the strongest single lever. Moving from 55% to 90% chunk cache hits across sessions cuts median chunk delivery time by roughly an order of magnitude for the affected population, without removing a single byte.
  • Instrumentation cost: under 1 KB gzipped and one beacon per session at a 10% sample rate — small enough to be invisible against the payload it measures.
  • Detection speed: same-day instead of same-quarter. A regression that only affects cold-cache mobile sessions is invisible in CI and obvious in field percentiles within hours of a release.
  • Attribution precision: chunk-level. Correlating vitals with in-flight chunks converts a page-level metric regression into a named chunk, which is the difference between a week of investigation and an afternoon.

Common pitfalls

Measuring only the median. Chunk delivery has a heavily skewed distribution by nature — a distant edge, a cold cache, a saturated radio. A healthy median with a 95th percentile several seconds worse is the normal shape of a real problem, not an outlier to be discarded.

Losing the critical chunks. An observer created after the initial chunks have already loaded records nothing about them unless buffered: true is set. Teams commonly instrument for months before noticing their most important chunks were never in the data.

Keying on hashed filenames. Without manifest resolution, every deploy fragments the dataset and trends become unreadable exactly when a release regression needs investigating.

Sampling per entry rather than per session. Sampling individual timings produces sessions with partial chunk sets, which breaks any analysis that asks what loaded together.

Transmitting eagerly. A request per metric multiplies request count on exactly the connections that are already struggling. Buffer, batch, and send once.

Ignoring the connection dimension. Aggregating across all connection types hides the population you most need to see. Segment by effective connection type before drawing any conclusion about whether a chunk is fast enough.

Lab Measurement Versus the Field Distribution A horizontal scale of chunk load time with the lab measurement far to the left, the field median moderately higher, and the 75th and 95th percentiles well past the budget threshold. Route chunk delivery time — one release, one week of field data 0 ms 1000 ms 2000 ms lab 60 ms p50 440 ms p75 970 ms p95 1730 ms budget: 680 ms The lab number clears the budget by 11×; three quarters of real sessions do not clear it at all

Verification workflow

  1. Confirm entries arrive for the critical chunks. In a fresh session, check that the collected set includes the entry and route chunks, not only the lazily-loaded ones. Missing critical chunks means buffered: true is absent or the script runs too late.

  2. Confirm cache classification is correct. Load once, reload, and verify the second session reports transferSize of zero for unchanged chunks. If everything reports as a cold fetch, cache headers are not doing what the deploy configuration claims.

  3. Confirm manifest resolution. After a deploy that changes one chunk’s hash, verify the dashboard still reports one continuous series per logical chunk name rather than two.

  4. Confirm the beacon fires exactly once. Background the tab, return, and navigate away; there should be one payload per session, not one per visibility change.

  5. Segment before concluding. Split every distribution by effective connection type and by cache status before comparing releases. An apparent regression is often a shift in traffic mix.

  6. Close the loop with the lab budget. For each chunk exceeding its field target at the 75th percentile, check whether its CI budget is set at a level that would have caught the growth, and tighten it if not — the mechanics are in failing builds on bundle size regressions.

FAQ

Why do chunks that pass a CI budget still feel slow in production?

Because a CI budget measures bytes and a user experiences time. The same 90 KB chunk resolves in 40 ms from a warm cache on a fast connection and in 1.8 seconds on a congested mobile network with a cold cache and a distant edge. Byte budgets are a necessary proxy that catches regressions early, but they cannot capture cache hit rate, edge distance, connection quality, or contention with other requests — all of which field measurement makes visible.

How do I map a hashed chunk filename back to something meaningful?

Emit a manifest at build time that maps logical chunk names to their hashed filenames, ship the reverse mapping to the client or resolve it during ingestion, and key every metric on the logical name. Without that step, each deploy produces a new set of filenames and every chart resets, making trends impossible to read. Resolving during ingestion is usually preferable because it keeps the mapping out of the client payload.

Does adding field instrumentation slow the page down?

It can, if it is written carelessly. Reading performance entries is cheap; sending them is not. The safe pattern is to observe passively, buffer in memory, and transmit once on page hide using a beacon, at a sampled share of sessions. What is expensive is a request per metric, synchronous serialization of large entry lists, or an observer that stays active and allocates for the entire session on a page that is open for hours.

Which percentile should a chunk-loading budget target?

Track the 75th percentile as the headline number, because that is the threshold Core Web Vitals assessment uses, and watch the 95th to catch failure modes that averages hide. A median that looks healthy alongside a 95th percentile several seconds worse usually means a specific population — a region far from your edge, a device class, or a cold-cache first visit — is having a categorically different experience that the median cannot show.