Budgeting Soft-Navigation INP in SPAs

In a single-page application the browser loads the shell once and then swaps views in JavaScript, so the interactions that matter most to users — tapping a nav item, opening a detail drawer, filtering a list — happen after the one hard navigation your synthetic tools measure. This page is part of the Single-Page App Performance Budgets reference, and it shows how to set and enforce an Interaction to Next Paint (INP) budget for each client-side route change, not just the cold load. If your budget is a single INP ceiling captured on one Lighthouse run against the landing route, you are grading the least representative moment of the session.

The fix is to treat every soft navigation as its own budgeted unit: attribute each interaction to the route it occurred on, aggregate INP at the 75th percentile per route and device class, and gate a scripted CI walk that reproduces the real click paths. Below is the entry type that makes this possible, a concrete per-route budget table, the instrumentation, and a copy-paste CI flow.

Why a Single Lighthouse Run Misses Soft-Navigation INP

A default Lighthouse mobile audit loads the entry URL, drives a scripted set of interactions during a short trace window, and reports one INP figure tied to that initial document. The moment your router intercepts a link click and re-renders without a full document load, Lighthouse's lab pass is already over. Meanwhile the user is three routes deep, mounting heavier components, hydrating lists, and firing the long tasks that actually inflate INP. Because the URL in the address bar changed without a navigation event, your beacon code — if it keyed metrics on the initial navigationStart — files every one of those slow interactions under the landing route. Your dashboards then show a healthy home page and hide a 480 ms INP on the search results view.

Soft navigations also reset the mental model users bring to a page. When someone clicks into a product detail view they expect a fresh, responsive surface; an interaction that lags 350 ms on mid-range mobile over Fast 3G reads as a broken app, even though the "page" (the document) never reloaded. Budgeting per route restores the accountability that per-metric ceilings give you elsewhere in Core Web Vitals Budget Allocation: each route earns its own ceiling and its own regression alarm.

Hard navigation versus soft navigations A session timeline showing the Lighthouse lab window covering only the initial load while three later route changes go unmeasured. One session, four surfaces Hard nav / (home) document load Lighthouse measures here soft nav /search INP 480 ms soft nav /product/42 INP 350 ms soft nav /cart INP 210 ms unmeasured by the single lab run but this is where the user spends the session
The lab window closes after the first document load, leaving the interactions that dominate the session ungraded.

The Soft-Navigation PerformanceObserver Entry Type

Chromium exposes a soft-navigation heuristic behind the soft-navigation performance entry type. When the engine detects that a user interaction led to a DOM change and a URL update without a hard navigation, it emits a soft-navigation entry carrying a navigationId, the name (the new URL), and a startTime. Crucially, subsequent event and interaction timing entries are then attributed to that navigationId, which is exactly the join key you need to bucket INP per route. You observe both streams and correlate them.

// soft-nav-observer.js — attribute interactions to the route they happened on
const routeByNavId = new Map();
let currentNavId = null;

// 1) Watch for soft navigations and remember the route they created.
new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    currentNavId = entry.navigationId;
    routeByNavId.set(entry.navigationId, new URL(entry.name).pathname);
  }
}).observe({ type: "soft-navigation", buffered: true });

// 2) Watch interactions; key each one to its owning navigation.
new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (!entry.interactionId) continue;
    const navId = entry.navigationId ?? currentNavId;
    const route = routeByNavId.get(navId) ?? location.pathname;
    recordInteraction(route, entry.duration);
  }
}).observe({ type: "event", durationThreshold: 40, buffered: true });

Because the feature is still gated in some channels, always feature-detect with PerformanceObserver.supportedEntryTypes.includes("soft-navigation") and fall back to keying on location.pathname at interaction time. The pathname fallback is less precise — it can misattribute an interaction that fires mid-transition — but it keeps every browser reporting something per route. This mirrors the approach in Injecting Custom Metrics via PerformanceObserver, where the observer is the seam between the platform and your beacon.

Attributing interactions to a route User click produces a soft-navigation entry with a navigationId that is used to bucket later event entries into a per-route INP store. User click tap a link soft-navigation navigationId + url event entries same navigationId per-route INP bucket navigationId is the join key Fallback: when soft-navigation is unsupported, key on location.pathname at interaction time.
The navigationId emitted with each soft navigation is the key that routes later interaction timings into the correct per-route bucket.

Per-Route INP Budgets by Device Class

INP is a P75 field metric, so every ceiling you set must name the percentile and the device-plus-connection context it applies to — the same discipline used in Calculating INP Thresholds for Interactive Dashboards. Cheap surfaces (a static marketing route) deserve a tighter ceiling than a data-dense results grid; a uniform 200 ms budget would either strangle the simple routes or wave through the expensive ones. The table below assigns a P75 INP ceiling per route, split by device class, and reserves headroom the way the Mobile vs Desktop Budget Divergence reference recommends.

Route Device class Connection INP P75 budget (ms) Rationale
/ (home) Mid-range mobile Fast 3G 200 Light shell, mostly nav taps
/ (home) Desktop Cable 130 Faster CPU, same interactions
/search Mid-range mobile Fast 3G 260 Filter + list re-render cost
/search Desktop Cable 160 Larger DOM but fast CPU
/product/:id Mid-range mobile Fast 3G 240 Image gallery + add-to-cart
/product/:id Desktop Cable 150 Gallery cheap on desktop
/cart Mid-range mobile Fast 3G 220 Quantity steppers, totals recompute
/checkout Mid-range mobile Fast 3G 200 Form input must feel instant
/dashboard Low-end mobile Slow 4G 300 Heavy charts, worst-case device

Read the ceilings as a promise at the 75th percentile: for /search on mid-range mobile over Fast 3G, three out of four real interactions must land at or under 260 ms. Set the low-end mobile /dashboard figure from your own field data rather than copying it — if your P75 on that route today is 420 ms, ratchet toward 300 ms in stages instead of failing every build on day one. When you have only a handful of samples per route, lean on the interpolation guidance in Choosing Between P75 and P90 Budget Targets before you trust a percentile.

Per-route INP P75 budgets on mid-range mobile Horizontal comparison of INP P75 ceilings across five routes with the 200 millisecond good threshold marked. INP P75 budget by route — mid-range mobile, Fast 3G 200 ms "good" line / home 200 /search 260 /product 240 /cart 220 Bars past the gold line spend their route's extra headroom on heavier re-render work.
Each route carries its own ceiling; the expensive results and product views buy headroom above the 200 ms good line while the home shell stays tight.

Instrumenting With the web-vitals Attribution Build

You do not have to hand-roll percentile math in the browser. The web-vitals attribution build reports each INP occurrence with the interaction target, the event type, and the loading phase, and it already understands soft navigations when you enable the option. Ship the smallest possible beacon that carries the route, the value, and the device context, then aggregate P75 server-side — the same division of labor covered in Building P75/P99 Aggregation Pipelines.

// rum-inp-beacon.js
import { onINP } from "web-vitals/attribution";

function deviceClass() {
  const mem = navigator.deviceMemory ?? 8;
  const cores = navigator.hardwareConcurrency ?? 8;
  if (mem <= 2 || cores <= 4) return "low-end-mobile";
  if (matchMedia("(pointer: coarse)").matches) return "mid-range-mobile";
  return "desktop";
}

onINP((metric) => {
  const body = JSON.stringify({
    route: metric.attribution.navigationEntry?.name
      ? new URL(metric.attribution.navigationEntry.name).pathname
      : location.pathname,
    inp: Math.round(metric.value),
    rating: metric.rating,           // "good" | "needs-improvement" | "poor"
    target: metric.attribution.interactionTarget,
    device: deviceClass(),
    effectiveType: navigator.connection?.effectiveType ?? "unknown"
  });
  navigator.sendBeacon("/rum/inp", body);
}, { reportAllChanges: false, reportSoftNavs: true });

The reportSoftNavs: true flag is what makes the library emit a fresh INP candidate per soft navigation instead of one aggregate for the whole page lifetime. Keep the payload lean and batch on the collector side; the field aggregation, not the browser, owns the P75 rollup that your budget compares against, exactly as the Custom Performance Beacons and RUM reference lays out.

A Scripted CI Flow That Clicks Through Routes

Field data tells you where you stand; a scripted lab walk is what gates the pull request before regressions reach the field. Use Playwright to drive the real click paths, harvest the same soft-navigation and event entries through an injected observer, and assert each route's P75 against the table. Throttle CPU and network so the numbers mean something on mid-range mobile hardware.

// ci/soft-nav-inp.spec.js
const { test, expect } = require("@playwright/test");

const BUDGETS = { "/search": 260, "/product/42": 240, "/cart": 220 };

test("soft-navigation INP stays within per-route budgets", async ({ page }) => {
  const client = await page.context().newCDPSession(page);
  await client.send("Emulation.setCPUThrottlingRate", { rate: 4 });

  await page.addInitScript(() => {
    window.__inp = {};
    new PerformanceObserver((list) => {
      for (const e of list.getEntries()) {
        if (!e.interactionId) continue;
        const route = location.pathname;
        window.__inp[route] = Math.max(window.__inp[route] ?? 0, e.duration);
      }
    }).observe({ type: "event", durationThreshold: 40, buffered: true });
  });

  await page.goto("https://staging.example.com/");
  await page.getByRole("link", { name: "Search" }).click();
  await page.getByPlaceholder("Filter results").fill("blue running shoes");
  await page.getByRole("link", { name: "Nimbus 42" }).click();
  await page.getByRole("button", { name: "Add to cart" }).click();
  await page.getByRole("link", { name: "Cart" }).click();

  const observed = await page.evaluate(() => window.__inp);
  for (const [route, budget] of Object.entries(BUDGETS)) {
    expect(observed[route], `INP on ${route}`).toBeLessThanOrEqual(budget);
  }
});

A single scripted pass gives you one INP sample per route, which is a worst-single-interaction proxy rather than a true P75. Run the walk several times in a matrix and take the median of each route's worst interaction so a one-off long task from a cold cache does not fail the build. That matrix pattern, and the CPU throttling rate that makes a CI runner behave like mid-range mobile, are covered in the calibration side of the reference.

Scripted CI walk with per-route assertions Playwright drives four routes in order while an injected observer captures the worst interaction on each, then each route is asserted against its budget. Playwright walk, one assertion per route goto / cold load click Search assert 260 ms open product assert 240 ms go to cart assert 220 ms observer: max interaction per route median across N matrix runs
The walk visits each route in order, an injected observer keeps the worst interaction per route, and the median across matrix runs is what the assertion gates.

Verifying the Budget Holds

Wire the spec into CI so a route that blows its ceiling fails the check the same way a byte-size regression does. The step below runs the walk, prints a per-route verdict, and exits non-zero on the first breach — pair it with the route-level payload gates from Route-Level Bundle Budgets for React Router so a build cannot ship a heavier route without both signals turning red.

# .github/workflows/soft-nav-inp.yml
name: soft-nav-inp
on: [pull_request]
jobs:
  inp-budget:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: "20"
      - run: npm ci
      - run: npx playwright install --with-deps chromium
      - name: Run soft-navigation INP walk (3x, take median)
        run: |
          for i in 1 2 3; do
            npx playwright test ci/soft-nav-inp.spec.js --reporter=json > "run-$i.json" || true
          done
          node ci/median-verdict.js run-1.json run-2.json run-3.json

To confirm the field side agrees with the lab gate, query your collector for the last seven days and compare the measured P75 per route against the budget. If the lab passes but the field P75 on /search for mid-range mobile is 300 ms, your CI CPU throttle is too gentle — calibrate it upward. Cross-check against the P75 methodology in Using 75th Percentile for Real-World INP Targets so lab and field are speaking the same statistic, and treat any persistent gap as a signal that your emulation, not your budget, needs tuning. Finally, remember INP is only one axis of a route change: pair it with the transition-paint work in Measuring Client-Side Transition LCP so a route can never pass on responsiveness while shipping a blank surface.

Frequently Asked Questions

Does the soft-navigation entry type work in every browser?

No. The soft-navigation heuristic is a Chromium feature and is still gated in some channels, and it is unavailable in browsers outside that engine. Feature-detect with PerformanceObserver.supportedEntryTypes and fall back to keying interactions on location.pathname so every browser still reports a per-route value, even if attribution is slightly less precise.

Why can't a single Lighthouse run give me per-route INP?

Lighthouse measures the interactions it drives during one lab window tied to the initial document load. Client-side route changes happen after that window closes, so their long interactions are never captured, or worse, get filed under the landing route. You need a RUM beacon or a scripted walk that observes interactions on each soft navigation.

What percentile should a soft-navigation INP budget use?

Use P75, the same percentile Core Web Vitals reports, and always pair it with a device class and connection. A budget like 260 ms on /search means the 75th percentile of real interactions on mid-range mobile over Fast 3G must stay at or under 260 ms. Set a stricter desktop ceiling separately.

My CI walk passes but field INP is worse. What's wrong?

The lab environment is faster than your users' devices. Raise the CPU throttling rate in the CDP session until a known route reproduces its field P75, then re-run. A single scripted pass is also only one sample, so run a matrix of several passes and gate on the median of each route's worst interaction.

How do I keep the RUM beacon cheap?

Send only the route, rounded INP value, rating, device class, and effective connection type, and use navigator.sendBeacon so it does not block unload. Do the P75 aggregation server-side rather than in the browser, and batch or sample high-traffic routes so the collector, not the client, carries the statistical load.