v151 · web api · shipped
PerformanceSoftNavigation and InteractionContentfulPaint performance entry
New PerformanceEntry types that let you measure Core Web Vitals — LCP, CLS, INP — for single-page application navigations that happen without a full page reload.
at a glance
| Shipped in | Chrome 151 (desktop + Android) |
|---|---|
| Status | Enabled by default |
| Flag | None (was chrome://flags/#soft-navigation-heuristics before Chrome 150) |
| Origin trial | Chrome 147–149 (final OT); shipped in 150 |
| Spec | Soft Navigations — WICG |
| Explainer | WICG / soft-navigations |
| ChromeStatus | 5144837209194496 — PerformanceSoftNavigation and InteractionContentfulPaint performance entry |
why it exists
Single-page applications (SPAs) have always been invisible to the Core Web Vitals (CWV) framework. CWV metrics like Largest Contentful Paint, Cumulative Layout Shift, and Interaction to Next Paint are defined relative to a navigation, and traditional navigation timing only fires on full page loads. When a React Router, Next.js, or SvelteKit app pushes a new URL and re-renders in-place, the browser saw no navigation — so no fresh CWV measurement was started. This meant a whole class of web experiences had no performance signal in Search Console, PageSpeed Insights, or field data.
Soft navigations defines a heuristic: if the user interacts (click, keyboard, etc.), the URL changes via History API or Navigation API, and significant DOM updates follow, the browser records a soft-navigation entry and resets the CWV baseline for that navigation scope.
shape of the API
Two new entry types are added to the Performance Timeline. Both are observed via the existing PerformanceObserver interface.
soft-navigation entry
Reported once per detected soft navigation. Extends PerformanceEntry.
| entryType | "soft-navigation" |
|---|---|
| name | The URL of the destination (after the pushState / Navigation API) |
| startTime | Time of the user interaction that triggered the navigation |
| duration | Time from interaction to the browser considering the navigation complete |
| navigationId | A unique string that groups all performance entries belonging to this soft navigation. Also added to all subsequent PerformanceEntry types (LCP, layout-shift, event-timing, etc.) within the navigation scope. |
interaction-contentful-paint entry
Analogous to largest-contentful-paint but scoped to a soft navigation. Extends PerformanceEntry.
| entryType | "interaction-contentful-paint" |
|---|---|
| renderTime | When the largest new element painted after the interaction |
| size | Size in pixels of the candidate element, same algorithm as LCP |
| element | Reference to the painted DOM element |
| navigationId | Links to the triggering soft-navigation entry |
navigationId on existing entries
All PerformanceEntry types gain a navigationId property. During the initial hard navigation its value is the same as performance.now() epoch identifier. After each soft navigation it changes to the new soft navigation's ID, so you can slice CLS, INP, and event-timing by navigation.
examples
observe soft navigations
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
console.log("Soft navigation to:", entry.name);
console.log(" Started:", entry.startTime);
console.log(" Navigation ID:", entry.navigationId);
}
}).observe({ type: "soft-navigation", buffered: true });
measure per-navigation LCP
const lcpByNav = new Map();
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
// Attribute to the current navigation scope.
lcpByNav.set(entry.navigationId, entry.renderTime || entry.loadTime);
}
}).observe({ type: "largest-contentful-paint", buffered: true });
// interaction-contentful-paint gives ICP for soft navigations:
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
lcpByNav.set(entry.navigationId, entry.renderTime);
console.log("ICP after soft nav:", entry.navigationId, entry.renderTime);
}
}).observe({ type: "interaction-contentful-paint", buffered: true });
slice CLS per navigation
const clsByNav = new Map();
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.hadRecentInput) continue;
const id = entry.navigationId; // new in Chrome 151
const prev = clsByNav.get(id) ?? 0;
clsByNav.set(id, prev + entry.value);
}
}).observe({ type: "layout-shift", buffered: true });
reading buffered entries
You can also query buffered entries directly without an observer:
// All soft navigations so far:
const softNavs = performance.getEntriesByType("soft-navigation");
// All ICP entries:
const icpEntries = performance.getEntriesByType("interaction-contentful-paint");
browser support
| Chrome / Edge | 150 (enabled by default); flag available from 147 |
|---|---|
| Firefox | No signal |
| Safari | No signal |
see also
- WICG/soft-navigations — spec repository and explainer
- Chrome blog: Final Soft Navigations origin trial (Chrome 147)
- Measuring soft navigations — Chrome developer docs
- chromestatus.com — PerformanceSoftNavigation and InteractionContentfulPaint performance entry (5144837209194496)
- chrome-platform-showcase: SoftNavigation demos