Single-Page App Performance Budgets

A single-page app quietly breaks the model that most budgets assume. This guide is part of the Defining Web Performance Budgets reference, and it exists because the classic budget contract — "point Lighthouse at a URL, measure first paint, gate the pull request" — describes exactly one moment in the life of an SPA: the cold document load. Everything a user does afterward, every filter they toggle, every product they open, every dashboard tab they switch to, happens as a client-side route change that never fires a browser navigation. The Lighthouse run sees none of it, so the budget you ship covers maybe the first ten seconds of a session that lasts ten minutes.

The consequence is a false green. Your CI dashboard shows a healthy Largest Contentful Paint on the shell, your bundle-size gate is satisfied by an initial chunk that lazy-loads everything, and meanwhile real users grind through 900 ms route transitions and janky filter interactions that never appear in any report. To budget an SPA honestly you have to measure the journey, not the entry point — and that means new instruments (the Soft Navigation Heuristics API), new units (per-route chunks and route-change INP), and a new enforcement shape (a scripted user flow instead of a single URL). This page walks through all of it.

Why Initial-Load Budgets Under-Cover SPAs

In a multi-page app, every meaningful screen is its own document. Navigating from the product list to the product detail unloads one page and loads another, which fires a hard navigation, restarts the metric clock, and gives Lighthouse a clean URL to audit. Budgets defined per URL naturally cover the whole app because every screen is a URL that a synthetic run can visit. In an SPA the router intercepts the click, calls history.pushState, swaps a component subtree, and the document never reloads. The navigation entry in the Performance Timeline stays frozen at the value it took during the cold load an hour ago.

So the metrics diverge sharply. The initial-load Largest Contentful Paint might be a lean 2.1 s at P75 on a mid-range Android phone throttled to Fast 3G, comfortably inside a 2.5 s ceiling — but that number describes the login shell, which is deliberately tiny. The screen users actually spend time on is the dashboard behind the login, whose route transition pulls a 240 KB chart chunk, hydrates a data grid, and paints a heavy contentful element 850 ms after the click. No hard navigation occurs, so no synthetic tool records that 850 ms, and your budget report happily stays green while the experience people pay for is slow.

Coverage gap between the cold load and the real journey The initial hard navigation is measured while the three subsequent client-side route changes are invisible to a single-URL audit. One session, one measured moment Cold load hard nav Open dashboard soft nav Filter results soft nav Open record soft nav Measured Never measured by a single-URL Lighthouse run The budget covers the tiny shell; the heavy screens live in the red zone.
A single-URL audit certifies the cold load and leaves every subsequent route change unbudgeted.

This is why an SPA needs budgets defined against route templates rather than a single entry URL, and why the enforcement job has to walk through the app the way a user does. Before we can gate anything, though, we need a way to attach LCP and INP to a route change that the browser does not consider a navigation at all.

The Soft-Navigation Heuristics API and Route-Change LCP and INP

The Soft Navigation Heuristics API is the browser feature that closes the measurement gap. It watches for the signature of a client-side navigation — a user interaction (a click or keypress) that is followed by a same-document URL change via the History API and a subsequent DOM modification that paints new content — and, when it sees that pattern, it emits a soft-navigation performance entry and restarts the LCP and interaction attribution for the new view. In other words, it teaches the browser to treat a route change as a navigation for metric purposes, so a route-change LCP and a per-route INP become observable quantities instead of guesses.

You subscribe with a PerformanceObserver. Enabling the includeSoftNavigationObservations flag makes the observer re-report largest-contentful-paint entries scoped to each soft navigation, and each entry carries a navigationId you can use to bucket interactions and paints to the correct route. The measurement contract changes subtly but importantly: a soft-navigation LCP is timed from the interaction that triggered the route change, not from a document startTime of zero, so the numbers you collect are genuinely "time from click to the heavy element appearing on the new screen."

// Observe soft navigations and the LCP that belongs to each one.
const softNavs = new Map(); // navigationId -> { name, startTime }

new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    softNavs.set(entry.navigationId, {
      name: entry.name,          // the URL after pushState
      startTime: entry.startTime // time of the triggering interaction
    });
    report('soft-nav', {
      route: routeTemplate(entry.name),
      navId: entry.navigationId,
      duration: entry.duration
    });
  }
}).observe({ type: 'soft-navigation', buffered: true });

// LCP entries now re-fire per soft navigation when this flag is set.
new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    const nav = softNavs.get(entry.navigationId);
    if (!nav) continue; // an initial-load LCP, budgeted separately
    report('route-lcp', {
      route: routeTemplate(nav.name),
      navId: entry.navigationId,
      lcpMs: Math.round(entry.startTime - nav.startTime)
    });
  }
}).observe({
  type: 'largest-contentful-paint',
  buffered: true,
  includeSoftNavigationObservations: true
});

Route-change INP is the interaction half of the same story, and it usually matters more than route-change LCP because SPAs are interaction machines. The Core Web Vitals Budget Allocation reference partitions a whole-page INP ceiling into per-metric room; in an SPA you extend that idea by allocating a separate INP ceiling to each route, because the interaction cost of a read-only marketing route and a live filter-heavy data grid have nothing in common. A reasonable starting posture is an INP ceiling of 200 ms at P75 on a mid-range Android device on a throttled 4G profile for content routes, tightening toward the same 200 ms at P90 for the handful of interaction-critical routes where lag is most visible. The dedicated child guide, Budgeting Soft-Navigation INP in SPAs, works through how to attribute a slow interaction to the route it occurred on.

How a soft navigation becomes measurable An interaction triggers a same-document URL change and a paint, which the heuristics API recognizes as a soft navigation and attributes new LCP and INP to. The soft-navigation signature User click interaction pushState URL changes DOM mutation new content soft-navigation entry clock restarts route-change LCP timed from the click route-change INP per-route ceiling Each new navigationId buckets its own LCP and INP for per-route budgeting.
The heuristics API recognizes interaction plus URL change plus paint as a navigation and reattributes metrics to it.

Per-Route JavaScript Chunk Budgets

The other half of an SPA budget is bytes, and here the initial-load view is doubly misleading. A well-code-split app makes its initial bundle look tiny precisely because it defers everything: the router loads a route's chunk only when the user first visits that route. So the number your bundle gate checks — the entry chunk — is not the number that governs the dashboard's transition time. What governs a route change is the route chunk plus its shared dependencies that must download and execute before the new view can hydrate and paint.

That means byte budgets in an SPA belong to route templates, exactly as the JavaScript Bundle Size Limits reference frames per-route payloads. You compute, for each lazy route, the transfer size a first-time visitor must fetch: the route's own chunk, any vendor chunks it uniquely pulls in, and the async CSS. A shared chart library imported by three routes is charged once to the shared bundle (which every route inherits) and not triple-counted, but a route that drags in a 90 KB grid library nobody else uses owns that 90 KB in its budget. The child guide Route-Level Bundle Budgets for React Router shows how to read those numbers out of a bundler's stats output and map them onto route definitions.

Per-route chunk sizes versus the ceiling Four route chunks charted against a shared 170 kilobyte per-route budget line, with the reports route exceeding it. Route chunk (gzip) vs 170 KB ceiling 0 120 200 170 KB budget 62 KB /list 118 KB /detail 158 KB /dashboard 228 KB /reports /reports busts the ceiling by 58 KB and needs its grid library split out.
Charging each route its own chunk exposes the one template that will make every soft navigation into it slow.

Note how the per-route lens localizes the problem. Aggregate "total JavaScript" would average the 228 KB reports route against three lean ones and pass. The per-route ceiling instead flags the exact template whose every visit will be slow, which is also the template whose soft-navigation LCP and INP you should expect to breach.

A common objection is that per-route budgets punish deep-linking: if a user cold-loads /reports directly, they pay for the entry chunk and the route chunk at once. That is real, and it is a reason to hold the initial-load budget and the route-change budget separately rather than folding them together. A first-time cold visit to a heavy route is a hard navigation and belongs to your initial-load LCP ceiling — a 2.5 s LCP at P75 on mid-range Android over Fast 3G — while the same route reached mid-session by a click belongs to its route-change LCP ceiling. Keeping the two ledgers distinct stops a deep-link edge case from loosening the ceiling that governs the far more common in-session transition.

A Budget Schema That Assigns Ceilings Per Route Template

With both instruments in place — soft-navigation metrics and per-route bytes — the budget becomes a small declarative document keyed by route template. Keying by template (/product/:id), not by concrete URL (/product/8842), keeps the schema finite and lets a new record inherit its template's ceilings automatically. Each entry states the byte ceiling for the route's chunk, a route-change LCP ceiling, and a route-change INP ceiling, each annotated with the percentile and the device-and-network profile it was calibrated against so nobody reads a raw millisecond number out of context.

{
  "profile": { "device": "mid-range-android", "network": "throttled-4g" },
  "routes": [
    {
      "template": "/list",
      "chunkBudgetKb": 170,
      "routeLcpMs": { "value": 1200, "percentile": "p75" },
      "routeInpMs": { "value": 200, "percentile": "p75" }
    },
    {
      "template": "/dashboard",
      "chunkBudgetKb": 170,
      "routeLcpMs": { "value": 1600, "percentile": "p75" },
      "routeInpMs": { "value": 200, "percentile": "p90" }
    },
    {
      "template": "/reports",
      "chunkBudgetKb": 170,
      "routeLcpMs": { "value": 1800, "percentile": "p75" },
      "routeInpMs": { "value": 200, "percentile": "p90" }
    }
  ]
}

The prose ceilings behind that schema, written out with their full context so they are auditable, look like this:

Route template Chunk (gzip) ceiling Route-change LCP Route-change INP Profile
/list 170 KB 1200 ms P75 200 ms P75 mid-range Android, throttled 4G
/detail 170 KB 1400 ms P75 200 ms P75 mid-range Android, throttled 4G
/dashboard 170 KB 1600 ms P75 200 ms P90 mid-range Android, throttled 4G
/reports 170 KB 1800 ms P75 200 ms P90 mid-range Android, throttled 4G

Two design choices are worth naming. First, interaction-critical routes (/dashboard, /reports) hold INP to 200 ms at the stricter P90 rather than P75, because on those screens a laggy filter is the whole complaint and you want the tail governed, not just the median-ish P75. Second, route-change LCP ceilings loosen for heavier routes rather than pretending a data-dense report can paint as fast as a list — an honest budget that the team can actually hold beats an aspirational one everyone learns to ignore.

Enforcing Budgets in CI With a Scripted User Flow

Here is the pivot that trips up most teams: you cannot enforce a soft-navigation budget by pointing Lighthouse at a URL, because the whole point is that the interesting screens are not URLs a cold audit can reach. Lighthouse's user-flow API exists exactly for this. You script a Puppeteer session that loads the app once (a navigation step), then drives the real interactions with flow.startTimespan() around each route change, so Lighthouse captures the client-side transition as its own measured segment. The CI job asserts each segment's metrics against that route's ceilings.

// lighthouse-flow.mjs — run under node with puppeteer + lighthouse installed
import puppeteer from 'puppeteer';
import { startFlow } from 'lighthouse';
import budget from './route-budget.json' assert { type: 'json' };

const browser = await puppeteer.launch({ headless: 'new' });
const page = await browser.newPage();
const flow = await startFlow(page, {
  config: {
    extends: 'lighthouse:default',
    settings: { formFactor: 'mobile', throttling: { cpuSlowdownMultiplier: 4 } }
  }
});

// 1. Cold load — the only hard navigation.
await flow.navigate('https://staging.example.com/list');

// 2. Soft navigation into the dashboard.
await flow.startTimespan({ name: '/dashboard' });
await page.click('[data-nav="dashboard"]');
await page.waitForSelector('[data-route-ready="dashboard"]');
await flow.endTimespan();

// 3. Soft navigation into reports.
await flow.startTimespan({ name: '/reports' });
await page.click('[data-nav="reports"]');
await page.waitForSelector('[data-route-ready="reports"]');
await flow.endTimespan();

const result = flow.createFlowResult();
await browser.close();

let failed = false;
for (const step of result.steps) {
  const rule = budget.routes.find((r) => r.template === step.name);
  if (!rule) continue;
  const inp = step.lhr.audits['interaction-to-next-paint']?.numericValue ?? 0;
  if (inp > rule.routeInpMs.value) {
    failed = true;
    console.error(`FAIL ${step.name}: INP ${Math.round(inp)}ms > ${rule.routeInpMs.value}ms`);
  }
}
process.exit(failed ? 1 : 0);

Wire that script into the same gate that runs your single-URL audits — the Lighthouse CI tooling reference covers the surrounding job — and the pull-request check now fails on a route-transition regression, not just a cold-load one. Because a scripted flow is more variance-prone than a cold audit (interactions land on a busier main thread), run each route segment three to five times and assert against the median so a single noisy timespan does not flip the gate red.

The scripted-flow gate A user flow drives soft navigations, each segment is asserted against its route ceilings, and any breach fails the pull request. One flow, per-route assertions Scripted flow load + drive clicks Per-segment metrics LCP + INP per route Median of 3-5 runs tame variance Compare to route ceiling Within budget - pass Breach - fail the PR
The gate walks the app like a user, medians each route segment, and fails on the first ceiling a transition busts.

Observability: RUM Soft-Nav Beacons

A scripted flow certifies the routes you thought to script on a synthetic device. Field data tells you which route transitions are actually slow for real users on real hardware — and in an SPA the two often disagree, because a soft navigation lands on a main thread already loaded with the previous route's work. So you close the loop with a RUM beacon that reports the soft-navigation metrics from the observers shown earlier. The mechanics of shipping those beacons efficiently belong to the Custom Performance Beacons and RUM reference; what matters for budgeting is that every beacon carries the route template and navigationId so you can aggregate a real P75 and P90 per route.

// Buffer per-route samples and flush them as one beacon on page hide.
const buffer = [];

function report(kind, sample) {
  buffer.push({ kind, ...sample, ts: Date.now() });
}

function flush() {
  if (!buffer.length) return;
  const body = JSON.stringify({
    session: sessionId,
    build: window.__BUILD_SHA__,
    samples: buffer.splice(0)
  });
  navigator.sendBeacon('/rum/soft-nav', body);
}

addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'hidden') flush();
});

On the ingestion side you compute per-route percentiles and compare them to the same ceilings the CI gate uses. When field P75 route-change INP on /reports drifts toward the 200 ms P90 line on mid-range Android over 4G while the synthetic gate stays green, that gap is your signal that the scripted device is kinder than real hardware — tighten the emulated CPU throttling or add the offending interaction to the flow. Budgets that are enforced in CI and watched in the field are the ones that survive contact with production.

Bucketing by build sha, as the beacon payload does, also lets you attribute a route-change regression to the deploy that caused it. If /dashboard route-change LCP was steady at 1.3 s at P75 on mid-range Android over 4G for a fortnight and jumps to 1.7 s the day a build lands, you have a suspect commit before a single user files a ticket. That is the same regression-detection posture you apply to hard-navigation metrics, just keyed by route template and navigation type — and it is why the RUM schema should never throw away which route a sample came from. Aggregate "app INP" would smear the dashboard regression across every route and hide it; the per-route field percentile keeps it sharp enough to alert on.

Failure Modes: Hydration Cost and Route-Transition CLS

Two SPA-specific failure modes deserve their own budget attention because they hide from naive metrics. The first is hydration cost. When a route change mounts a component tree, the framework attaches event listeners and runs effects before the view is interactive; a route can paint quickly (good LCP) yet stay unresponsive for hundreds of milliseconds while hydration monopolizes the main thread, which shows up only as a bad route-change INP on the first interaction. Budget for it by asserting INP on an interaction immediately after the transition in your flow, not just the transition itself, so a route that paints fast but hydrates slowly still fails.

The second is route-transition Cumulative Layout Shift. A soft navigation that swaps content without reserving space — a spinner replaced by a taller data grid, an image block whose dimensions arrive late — shifts layout after the user's eyes have settled, and because CLS accumulates within a soft-navigation window it counts against that route. Reserve space with skeletons sized to the incoming content and hold route-change CLS to 0.1 at P75 on mid-range Android over 4G, the same tight bound you would apply on a hard navigation. The child guide Measuring Client-Side Transition LCP goes deeper on separating paint timing from these hydration and shift effects so each gets its own ceiling.

Taken together, these failure modes explain why an SPA budget is a small suite — per-route bytes, route-change LCP, route-change INP, and route-transition CLS — rather than a single number. Each catches a different way a client-side navigation can be slow while the cold-load report stays a reassuring, and misleading, green.

Frequently Asked Questions

Why does my initial-load Lighthouse score stay green while users complain the app is slow?

Because a single-URL Lighthouse run only measures the cold document load — the hard navigation. Every screen a user reaches by clicking is a client-side route change that never fires a browser navigation, so its LCP, INP, and CLS are invisible to that run. You need soft-navigation measurement and a scripted user flow to see the real journey.

What is the Soft Navigation Heuristics API and what does it change?

It is a browser feature that recognizes the signature of a client-side navigation — an interaction, a same-document URL change, and a subsequent paint — and emits a soft-navigation performance entry that restarts LCP and interaction attribution for the new view. It turns route-change LCP and per-route INP into measurable quantities you can budget against.

How do I enforce SPA budgets in CI if the routes are not real URLs?

Use Lighthouse's user-flow API to script a Puppeteer session that loads the app once and then drives each route change inside a timespan, so Lighthouse measures each client-side transition as its own segment. Assert each segment's metrics against that route's ceilings, and run three to five times taking the median to tame variance.

Should JavaScript budgets in an SPA be per-route or a single total?

Per-route. A single total averages a heavy route against lean ones and passes, hiding the one template whose every visit is slow. Charge each lazy route its own chunk plus the vendor code it uniquely imports, count shared libraries once against the shared bundle, and give each route template a byte ceiling calibrated to a stated device and network.

Why can a route change have a good LCP but still feel unresponsive?

Hydration. The new view can paint quickly yet stay non-interactive for hundreds of milliseconds while the framework attaches listeners and runs effects, which shows up only as a poor route-change INP on the first interaction. Assert INP on an interaction right after the transition, not just the transition's paint, so a fast-painting but slow-hydrating route still fails the gate.