Heuristics to detect Single Page Apps soft navigations
66
stars
129
commits
Bikeshed
primary language
Aug 26, 2026
updated
Modern web applications often dynamically update content in response to user interactions, without performing a full cross-document navigation to do so. The existing Web Performance Timeline APIs do not provide a mechanism to measure the performance of such user experiences.
This repository hosts a specification for two new PerformanceEntry types:
InteractionContentfulPaint: Reports on new contentful paints that are initiated by, and attributed to, user interactions.
LargestContentfulPaint entry, representing the single largest element rendered as a result of that interaction.PerformanceSoftNavigation: Reports on user-initiated same-document navigations.
PerformanceEntry entries, and give them a URL to attribute to.PaintTimingMixin), and defines a new timeOrigin for subsequent entries (via its startTime).This specification also defines an extension to all existing PerformanceEntry types:
navigationId attribute, which can be used to "slice" the performance timeline data into useful sub-parts.PerformanceSoftNavigation becomes one mechanism for slicing, though other page lifecycle events (i.e., pageshow for bfcache restorations, etc.) are also common reasons.Finally, this specification also proposes several modifications to existing specifications to support these new APIs.
The web Performance Timeline and related specifications define a rich set of capabilities for measuring the performance of pages. These help developers monitor, understand, and improve user experience.
From these primitives, an interoperable set of metrics is defined, such as First Contentful Paint (FCP), Largest Contentful Paint (LCP), and Interaction to Next Paint (INP).
However, those specifications, and the metrics defined in terms of them, are currently tied to cross-document navigations, aka "hard" page loads. I.e., you only get paint timings for the initial page load, and all other timings are reported with timestamps relative the original navigation start, and typically attributed to the initial document URL.
Yet, many modern web applications will not always choose to "hard" navigate between distinct pages on every interaction. Sites might instead only partially update existing page contents in response to user interactions. Some sites might even be designed as Single Page Applications, though modern practice is to leverage a mixture of cross-document and same-document interactions/navigations.
Problem: Such sites currently do not fully benefit from the existing Performance Timeline APIs.
click handler initiates a network fetch().To the user, this feels exactly like a "navigation." To the performance timeline, the new URL is irrelevant, and the eventual paint updates are unmeasured.
No formal user research has been conducted for this proposal yet.
Instead, we investigated existing techniques and best practices used by web frameworks and client side routers to measure and observe soft navigations.
The proposed solution was evaluated, and evolved, through several rounds of Origin Trial feedback and developer testing in Chromium.
The APIs proposed in the specifications contained within this repository create an elegant mechanism to address this existing gap:
By measuring the "loading performance" of all interactions, summarized into a single nested "LCP" for each interaction, and by observing same-document history changes initiated by those same interactions — we can define and measure soft navigations and their subsequent loading performance (i.e., "soft" LCP).
This specification mostly leverages and brings together several existing web platform capabilities, as well as a few new nascent feature incubations:
interactionId. We extend Event Timing to add support for navigate, popstate, and hashchange events.InteractionContext, which is stored in an internal AsyncContext.Variable. This gets propagated through asynchronous operations (like fetch() or setTimeout), ensuring that the eventual effects of that interaction can be attributed back to the original user interaction.appendChild, innerHTML, style or src attributes, etc.), and the modification is from a task that is associated with an InteractionContext (via AsyncContext), we "mark" that part of the DOM as being associated with that interaction.InteractionContentfulPaint is an aspirational future goal).This proposal has a dependency on the following non-stable or proposed web platform features:
AsyncContext for propagating the InteractionContext across asynchronous boundaries (such as network requests or timers).
AsyncContext JavaScript API, but instead uses an internal Chromium mechanism known as "Task Attribution." Task Attribution is expected to power the public AsyncContext API when implemented, and the two systems are expected to stay aligned.new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
const {
startTime,
duration,
interactionId,
largestContentfulPaint,
} = entry;
console.log(
"[ICP] interactionId:", interactionId,
"startTime:", startTime,
"duration:", duration,
"LCP element (so far):", largestContentfulPaint.element,
"LCP size (so far):", largestContentfulPaint.size
);
}
}).observe({
type: "interaction-contentful-paint",
buffered: true // Optional
});
To observe the stream of new soft navigations, you can either use a PerformanceObserver:
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
const {
startTime,
duration,
interactionId,
navigationId,
} = entry;
const url = entry.name;
// Optional: Retrieve the largest ICP for this soft navigation so far.
// Note: This keeps updating as the page loads beyond FCP, so you can read the final value when you are ready to report/beacon.
const icpEntry = entry.getLargestInteractionContentfulPaint();
const lcpElement = icpEntry?.largestContentfulPaint?.element;
console.log(
"[SoftNav] interactionId:", interactionId,
"startTime:", startTime,
"url:", url,
"fcp:", duration,
"lcp element (so far):", lcpElement
);
}
}).observe({
type: "soft-navigation",
buffered: true, // Optional
});
Or, use performance.getEntriesByType():
const soft_navs = performance.getEntriesByType("soft-navigation");
Note: The latter is limited by the global buffer size for this entry type, so using a PerformanceObserver is recommended.
[!NOTE] Retrieving LCP from soft navigations (and its tradeoffs): Once a soft navigation is detected and emitted as a
soft-navigationPerformanceEntry, developers often want to report its final/largest contentful paint (LCP) value to their analytics beacon. To make this convenient, thePerformanceSoftNavigationentry provides agetLargestInteractionContentfulPaint()getter method.This method returns the largest
InteractionContentfulPaintobserved during the soft navigation's interaction context. This allows developers to keep a reference to the soft navigation entry as it is emitted, wait for page unload or other beaconing criteria, and report the last value of LCP directly from this nested getter.However, doing so has some tradeoffs: if you wait too long (e.g. at page unload), the nested LCP element reference (
largestContentfulPaint.element) may have already been garbage-collected, removed from the DOM, or detached, returningnull. For real-time tracking, element inspection, or robust bookkeeping, developers should instead subscribe tointeraction-contentful-paintentries for real-time observation.
All PerformanceEntry types can be mapped to a navigation using a navigationId value.
From this, you can extract:
startTime (or activationStart)namefunction getNavigationEntry(navigationId) {
const navs = [
performance.getEntriesByType('navigation')[0],
...performance.getEntriesByType('soft-navigation'),
];
return navs.find(entry => entry.navigationId === navigationId);
}
Note: This specification does not define it, but it would be a useful future extension to also add (e.g., bfcache restorations) to this list.
The initial solution for detecting soft navigations relied on stronger, baked-in heuristics. For example:
hashchange only).These heuristics were meant to approximate existing cross-document navigations, and support use cases that blended both hard and soft navigation data. There is also some value in having a standard set of criteria that are baked in and consistently applied across sites.
However, these self-imposed limitations (heuristics) also reduced the utility of the feature for many real-world use cases, and reduced the quality of the performance data for many sites. It also made the implementation more complex, rather than easier. Over time, the feedback from developers was to relax these heuristics and provide a simpler, more flexible solution.
The single biggest change was to decouple the task of reporting InteractionContentfulPaint from reporting PerformanceSoftNavigation. This simplifies implementation complexity, and it has proved useful as a general-purpose tool for measuring the performance of user interactions, even when those interactions don't result in a navigation of any kind.
Some fundamental requirements do remain:
But the remaining "heuristics" are left to the developer to enforce. For example, the navigationType and URL are exposed, so the developer may filter or group as desired.
Another alternative explored was to observe effects such as interactions, same-document navigations, and paints, just as global effects, and then tie them together with a simple timer—i.e., via Transient User Activation.
However, this created a problem: although most interactions provide a fast response, performance data from the field is most useful for finding slow outliers. We know from aggregate field data that navigation loading surprisingly often takes between 4 and 10 seconds on slow devices. This suggests that any timer-based cut-off value should not be less than 10 seconds, and potentially much larger.
But users are typically interacting at least once every few seconds. Thus, at least after the initial interaction, a page would nearly always be in a state of having an "active" interaction.
We considered observing only changes to specific semantic elements (i.e., <main> or <article> sections of the page), but this does not seem to match current real-world practices.
We could consider limiting the amount of soft navigations detected in a certain timeframe (e.g., X per Y seconds), if we'd see that some web applications detect an excessive amount of soft navigations that don't correspond to the user experience.
(Note: This section is incomplete.)
LargestContentfulPaint entryType directly, with a "soft" mode filter.LargestContentfulPaint inside. This is the shape of the API today.Exposing these entries does not introduce significant novel privacy risks.
For a detailed analysis, see the W3C TAG Security & Privacy Self-Review Questionnaire (SP-questions.md) and the Security & Privacy section of the specification.
Many thanks for valuable feedback and advice from the members of the W3C Web Performance Working Group and all of the contributors to this repository.
Thanks to the following proposals, projects, and specifications for their work on related problems that influenced this proposal:
Bikeshed
98.7%
Makefile
1.3%
Heuristics to detect Single Page Apps soft navigations
66
stars
129
commits
Bikeshed
primary language
Aug 26, 2026
updated
Modern web applications often dynamically update content in response to user interactions, without performing a full cross-document navigation to do so. The existing Web Performance Timeline APIs do not provide a mechanism to measure the performance of such user experiences.
This repository hosts a specification for two new PerformanceEntry types:
InteractionContentfulPaint: Reports on new contentful paints that are initiated by, and attributed to, user interactions.
LargestContentfulPaint entry, representing the single largest element rendered as a result of that interaction.PerformanceSoftNavigation: Reports on user-initiated same-document navigations.
PerformanceEntry entries, and give them a URL to attribute to.PaintTimingMixin), and defines a new timeOrigin for subsequent entries (via its startTime).This specification also defines an extension to all existing PerformanceEntry types:
navigationId attribute, which can be used to "slice" the performance timeline data into useful sub-parts.PerformanceSoftNavigation becomes one mechanism for slicing, though other page lifecycle events (i.e., pageshow for bfcache restorations, etc.) are also common reasons.Finally, this specification also proposes several modifications to existing specifications to support these new APIs.
The web Performance Timeline and related specifications define a rich set of capabilities for measuring the performance of pages. These help developers monitor, understand, and improve user experience.
From these primitives, an interoperable set of metrics is defined, such as First Contentful Paint (FCP), Largest Contentful Paint (LCP), and Interaction to Next Paint (INP).
However, those specifications, and the metrics defined in terms of them, are currently tied to cross-document navigations, aka "hard" page loads. I.e., you only get paint timings for the initial page load, and all other timings are reported with timestamps relative the original navigation start, and typically attributed to the initial document URL.
Yet, many modern web applications will not always choose to "hard" navigate between distinct pages on every interaction. Sites might instead only partially update existing page contents in response to user interactions. Some sites might even be designed as Single Page Applications, though modern practice is to leverage a mixture of cross-document and same-document interactions/navigations.
Problem: Such sites currently do not fully benefit from the existing Performance Timeline APIs.
click handler initiates a network fetch().To the user, this feels exactly like a "navigation." To the performance timeline, the new URL is irrelevant, and the eventual paint updates are unmeasured.
No formal user research has been conducted for this proposal yet.
Instead, we investigated existing techniques and best practices used by web frameworks and client side routers to measure and observe soft navigations.
The proposed solution was evaluated, and evolved, through several rounds of Origin Trial feedback and developer testing in Chromium.
The APIs proposed in the specifications contained within this repository create an elegant mechanism to address this existing gap:
By measuring the "loading performance" of all interactions, summarized into a single nested "LCP" for each interaction, and by observing same-document history changes initiated by those same interactions — we can define and measure soft navigations and their subsequent loading performance (i.e., "soft" LCP).
This specification mostly leverages and brings together several existing web platform capabilities, as well as a few new nascent feature incubations:
interactionId. We extend Event Timing to add support for navigate, popstate, and hashchange events.InteractionContext, which is stored in an internal AsyncContext.Variable. This gets propagated through asynchronous operations (like fetch() or setTimeout), ensuring that the eventual effects of that interaction can be attributed back to the original user interaction.appendChild, innerHTML, style or src attributes, etc.), and the modification is from a task that is associated with an InteractionContext (via AsyncContext), we "mark" that part of the DOM as being associated with that interaction.InteractionContentfulPaint is an aspirational future goal).This proposal has a dependency on the following non-stable or proposed web platform features:
AsyncContext for propagating the InteractionContext across asynchronous boundaries (such as network requests or timers).
AsyncContext JavaScript API, but instead uses an internal Chromium mechanism known as "Task Attribution." Task Attribution is expected to power the public AsyncContext API when implemented, and the two systems are expected to stay aligned.new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
const {
startTime,
duration,
interactionId,
largestContentfulPaint,
} = entry;
console.log(
"[ICP] interactionId:", interactionId,
"startTime:", startTime,
"duration:", duration,
"LCP element (so far):", largestContentfulPaint.element,
"LCP size (so far):", largestContentfulPaint.size
);
}
}).observe({
type: "interaction-contentful-paint",
buffered: true // Optional
});
To observe the stream of new soft navigations, you can either use a PerformanceObserver:
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
const {
startTime,
duration,
interactionId,
navigationId,
} = entry;
const url = entry.name;
// Optional: Retrieve the largest ICP for this soft navigation so far.
// Note: This keeps updating as the page loads beyond FCP, so you can read the final value when you are ready to report/beacon.
const icpEntry = entry.getLargestInteractionContentfulPaint();
const lcpElement = icpEntry?.largestContentfulPaint?.element;
console.log(
"[SoftNav] interactionId:", interactionId,
"startTime:", startTime,
"url:", url,
"fcp:", duration,
"lcp element (so far):", lcpElement
);
}
}).observe({
type: "soft-navigation",
buffered: true, // Optional
});
Or, use performance.getEntriesByType():
const soft_navs = performance.getEntriesByType("soft-navigation");
Note: The latter is limited by the global buffer size for this entry type, so using a PerformanceObserver is recommended.
[!NOTE] Retrieving LCP from soft navigations (and its tradeoffs): Once a soft navigation is detected and emitted as a
soft-navigationPerformanceEntry, developers often want to report its final/largest contentful paint (LCP) value to their analytics beacon. To make this convenient, thePerformanceSoftNavigationentry provides agetLargestInteractionContentfulPaint()getter method.This method returns the largest
InteractionContentfulPaintobserved during the soft navigation's interaction context. This allows developers to keep a reference to the soft navigation entry as it is emitted, wait for page unload or other beaconing criteria, and report the last value of LCP directly from this nested getter.However, doing so has some tradeoffs: if you wait too long (e.g. at page unload), the nested LCP element reference (
largestContentfulPaint.element) may have already been garbage-collected, removed from the DOM, or detached, returningnull. For real-time tracking, element inspection, or robust bookkeeping, developers should instead subscribe tointeraction-contentful-paintentries for real-time observation.
All PerformanceEntry types can be mapped to a navigation using a navigationId value.
From this, you can extract:
startTime (or activationStart)namefunction getNavigationEntry(navigationId) {
const navs = [
performance.getEntriesByType('navigation')[0],
...performance.getEntriesByType('soft-navigation'),
];
return navs.find(entry => entry.navigationId === navigationId);
}
Note: This specification does not define it, but it would be a useful future extension to also add (e.g., bfcache restorations) to this list.
The initial solution for detecting soft navigations relied on stronger, baked-in heuristics. For example:
hashchange only).These heuristics were meant to approximate existing cross-document navigations, and support use cases that blended both hard and soft navigation data. There is also some value in having a standard set of criteria that are baked in and consistently applied across sites.
However, these self-imposed limitations (heuristics) also reduced the utility of the feature for many real-world use cases, and reduced the quality of the performance data for many sites. It also made the implementation more complex, rather than easier. Over time, the feedback from developers was to relax these heuristics and provide a simpler, more flexible solution.
The single biggest change was to decouple the task of reporting InteractionContentfulPaint from reporting PerformanceSoftNavigation. This simplifies implementation complexity, and it has proved useful as a general-purpose tool for measuring the performance of user interactions, even when those interactions don't result in a navigation of any kind.
Some fundamental requirements do remain:
But the remaining "heuristics" are left to the developer to enforce. For example, the navigationType and URL are exposed, so the developer may filter or group as desired.
Another alternative explored was to observe effects such as interactions, same-document navigations, and paints, just as global effects, and then tie them together with a simple timer—i.e., via Transient User Activation.
However, this created a problem: although most interactions provide a fast response, performance data from the field is most useful for finding slow outliers. We know from aggregate field data that navigation loading surprisingly often takes between 4 and 10 seconds on slow devices. This suggests that any timer-based cut-off value should not be less than 10 seconds, and potentially much larger.
But users are typically interacting at least once every few seconds. Thus, at least after the initial interaction, a page would nearly always be in a state of having an "active" interaction.
We considered observing only changes to specific semantic elements (i.e., <main> or <article> sections of the page), but this does not seem to match current real-world practices.
We could consider limiting the amount of soft navigations detected in a certain timeframe (e.g., X per Y seconds), if we'd see that some web applications detect an excessive amount of soft navigations that don't correspond to the user experience.
(Note: This section is incomplete.)
LargestContentfulPaint entryType directly, with a "soft" mode filter.LargestContentfulPaint inside. This is the shape of the API today.Exposing these entries does not introduce significant novel privacy risks.
For a detailed analysis, see the W3C TAG Security & Privacy Self-Review Questionnaire (SP-questions.md) and the Security & Privacy section of the specification.
Many thanks for valuable feedback and advice from the members of the W3C Web Performance Working Group and all of the contributors to this repository.
Thanks to the following proposals, projects, and specifications for their work on related problems that influenced this proposal:
Bikeshed
98.7%
Makefile
1.3%