Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
93 commits
Select commit Hold shift + click to select a range
42ccabc
Initial LCP support
tunetheweb Jan 16, 2023
d995891
Add FCP support
tunetheweb Jan 16, 2023
d2fc3ec
Comments
tunetheweb Jan 16, 2023
9b8202d
Support buffered entries
tunetheweb Jan 17, 2023
d2e0ab6
Simplify LCP using performance.getEntriesByType() instead
tunetheweb Jan 19, 2023
97fd350
Bug fix
tunetheweb Jan 19, 2023
03f8448
Fix FCP too
tunetheweb Jan 19, 2023
74b1231
Linting
tunetheweb Jan 19, 2023
50e9ded
Add TTFB support
tunetheweb Jan 19, 2023
dca3482
README updates
tunetheweb Jan 19, 2023
a387712
Clean up Metric
tunetheweb Jan 19, 2023
50d8dc1
Add FID support
tunetheweb Jan 19, 2023
5fa268c
Tidy up
tunetheweb Jan 19, 2023
4f558ee
Add CLS
tunetheweb Jan 19, 2023
01ab0ed
Allow FID to show after hiding for soft navs
tunetheweb Jan 19, 2023
9e038c3
Implement CLS
tunetheweb Jan 19, 2023
d12ce69
Move from pageUrl to navigationId
tunetheweb Jan 20, 2023
9f87d85
Cleanup and fixes
tunetheweb Jan 20, 2023
1700428
Fix attribution
tunetheweb Jan 20, 2023
d0a4f19
Initial INP support
tunetheweb Jan 20, 2023
c5984e5
Finalize LCP on softnav change
tunetheweb Jan 20, 2023
9afc257
Switch LCP to process all entries so reportAllChanges works
tunetheweb Jan 20, 2023
7e316eb
Bug fixes
tunetheweb Jan 20, 2023
ecf82d4
Update package
tunetheweb Feb 3, 2023
f013531
Bump version number
tunetheweb Feb 8, 2023
2d7ea75
Fix onLCP for multiple callbacks
tunetheweb Feb 9, 2023
2e63c81
Update version
tunetheweb Feb 9, 2023
bb49653
Fix FID and FCP navigation type
tunetheweb Feb 9, 2023
60dde0b
Add support includeSoftNavigationObservations
tunetheweb Feb 10, 2023
aa45395
Update version
tunetheweb Feb 10, 2023
b4b28b0
Fix includeSoftNavigationObservations bug
tunetheweb Feb 11, 2023
f381326
Merge branch 'main' into soft-navs
tunetheweb Mar 6, 2023
e11c6b8
Merge branch 'main' into soft-navs
tunetheweb Mar 9, 2023
99d2b9b
Merge branch 'main' into soft-navs
tunetheweb Mar 9, 2023
5cdb8f9
Merge branch 'main' into soft-navs
tunetheweb Apr 4, 2023
090e7b2
Merge branch 'main' into soft-navs
tunetheweb May 29, 2023
39a043a
Merge branch 'main' into soft-navs
tunetheweb Jul 10, 2023
9c1e68c
Support navigationIds that are UUIDs
tunetheweb Jul 10, 2023
5cc8b86
UUID cleanup
tunetheweb Jul 10, 2023
94dd1cc
More clean up
tunetheweb Jul 10, 2023
f659c15
Avoid repeated soft nav lookups
tunetheweb Jul 10, 2023
607041f
More cleanup
tunetheweb Jul 10, 2023
db653ae
Cleanup and comments
tunetheweb Jul 10, 2023
173d87f
Even more cleanup and comments
tunetheweb Jul 11, 2023
71c5927
Fix comments
tunetheweb Jul 11, 2023
9d87a20
No INP unless supported by that browser
tunetheweb Jul 12, 2023
b39d261
Restore comment
tunetheweb Jul 12, 2023
9835574
Remove unnecessary ?
tunetheweb Jul 12, 2023
e373b67
Remove optional chaining
tunetheweb Jul 12, 2023
5de5e89
Merge branch 'main' into soft-navs
tunetheweb Sep 28, 2023
7c00038
Fix attribution for TTFB and LCP
tunetheweb Dec 13, 2023
2dea9a2
Fix FCP and LCP attributions
tunetheweb Dec 13, 2023
1e72319
Fix attribution of reused resource
tunetheweb Dec 13, 2023
42b8e47
Reset visibilitywatcher on soft nav
tunetheweb Dec 15, 2023
5b5c81c
Bump version number
tunetheweb Dec 15, 2023
1af7905
Merge branch 'main' into soft-navs
tunetheweb Dec 28, 2023
3ebf57f
Linting
tunetheweb Dec 28, 2023
5ab847d
Merge branch 'main' into soft-navs
tunetheweb Jan 26, 2024
8b8bd7f
Merge branch 'main' into soft-navs
tunetheweb Mar 19, 2024
44c6e07
Merge branch 'main' into soft-navs
tunetheweb Jul 31, 2024
cd5a2ee
Fix unit tests
tunetheweb Jul 31, 2024
f960498
Fix LCP on inputs for soft navs
tunetheweb Aug 1, 2024
57b751e
Bump version for npm publish
tunetheweb Aug 1, 2024
8a42838
Merge branch 'main' into soft-navs
tunetheweb Aug 4, 2024
a44dd93
Merge branch 'main' into soft-navs
tunetheweb Aug 12, 2024
ce847cb
Merge branch 'main' into soft-navs
tunetheweb May 15, 2025
b541a2c
Merge branch 'main' into soft-navs
tunetheweb May 30, 2025
3b659d1
Add navigationURL
tunetheweb May 31, 2025
15f8fe5
Fix TTFB attribution for soft navs
tunetheweb Jun 4, 2025
1966e82
Allow old LCPs for softnavs
tunetheweb Jun 4, 2025
71d9a5c
Remove mention of FID
tunetheweb Jun 5, 2025
b8b2860
Merge branch 'main' into soft-navs
tunetheweb Jun 11, 2025
9e4218d
Handle non-INP events and late entries better
tunetheweb Jun 18, 2025
015bba3
takerecords when finalising
tunetheweb Jun 18, 2025
a2f7ce2
Merge branch 'main' into soft-navs
tunetheweb Jul 15, 2025
3d5fbb8
Fix bugs
tunetheweb Jul 15, 2025
36ca8f1
Upgrade to latest InteractionContentfulPaint implementation
tunetheweb Jul 15, 2025
95eb8ad
Fix bug
tunetheweb Jul 24, 2025
ae3b39b
Merge branch 'main' into soft-navs
tunetheweb Jul 31, 2025
9bdc2f8
Fix background tab bug
tunetheweb Sep 18, 2025
c7f5789
Merge branch 'main' into soft-navs
tunetheweb Dec 16, 2025
4b04720
Merge branch 'main' into soft-navs
tunetheweb Jan 31, 2026
1cc4573
Merge branch 'main' into soft-navs
tunetheweb Mar 25, 2026
60e0f4a
Upgrade to the latest soft nav implementation
tunetheweb Mar 25, 2026
629b644
Better types
tunetheweb Mar 25, 2026
2a45624
Fix softLCP logic for Chrome bug
tunetheweb Mar 26, 2026
42b8851
Simplify logic
tunetheweb Mar 26, 2026
4d744ff
Refactor to handle buffered values better
tunetheweb Mar 26, 2026
0984a49
Cleanup
tunetheweb Mar 26, 2026
9e4d8af
Fix onCLS bug
tunetheweb Mar 26, 2026
b27ae92
onINP fix
tunetheweb Mar 27, 2026
d859330
Bump package number
tunetheweb Mar 27, 2026
4822343
Remove includeSoftNavigationObservations
tunetheweb Mar 28, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 80 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,66 @@ onLCP(logDelta);

In addition to using the `id` field to group multiple deltas for the same metric, it can also be used to differentiate different metrics reported on the same page. For example, after a back/forward cache restore, a new metric object is created with a new `id` (since back/forward cache restores are considered separate page visits).

### Report metrics for soft navigations (experimental)

_**Note:** this is experimental and subject to change._

Currently Core Web Vitals are only tracked for full page navigations, which can affect how [Single Page Applications](https://web.dev/vitals-spa-faq/) that use so called "soft navigations" to update the browser URL and history outside of the normal browser's handling of this. The Chrome team are experimenting with being able to [measure these soft navigations](https://github.com/WICG/soft-navigations) separately and report on Core Web Vitals separately for them.

This experimental support allows sites to measure how their Core Web Vitals might be measured differently should this happen.

At present a "soft navigation" is defined as happening after the following three things happen:

- A user interaction occurs
- The URL changes
- Content is added to the DOM
- Something is painted to screen.

For some sites, these heuristics may lead to false positives (that users would not really consider a "navigation"), or false negatives (where the user does consider a navigation to have happened despite not missing the above criteria). We welcome feedback at https://github.com/WICG/soft-navigations/issues on the heuristics, at https://crbug.com for bugs in the Chrome implementation, and on [https://github.com/GoogleChrome/web-vitals/pull/308](this pull request) for implementation issues with web-vitals.js.

_**Note:** At this time it is not known if this experiment will be something we want to move forward with. Until such time, this support will likely remain in a separate branch of this project, rather than be included in any production builds. If we decide not to move forward with this, the support of this will likely be removed from this project since this library is intended to mirror the Core Web Vitals as much as possible._

Some important points to note:

- TTFB is reported as 0, and not the time of the first network call (if any) after the soft navigation.
- FCP and LCP are the first and largest contentful paints after the soft navigation. Prior reported paint times will not be counted for these metrics, even though these elements may remain between soft navigations, and may be the first or largest contentful item.
- INP is reset to measure only interactions after the the soft navigation.
- CLS is reset to measure again separate to the first page.

_**Note:** It is not known at this time whether soft navigations will be weighted the same as full navigations. No weighting is included in this library at present and metrics are reported in the same way as full page load metrics._

The metrics can be reported for Soft Navigations using the `reportSoftNavs: true` reporting option:

```js
import {
onCLS,
onINP,
onLCP,
} from 'https://unpkg.com/web-vitals@soft-navs/dist/web-vitals.js?module';

onCLS(console.log, {reportSoftNavs: true});
onINP(console.log, {reportSoftNavs: true});
onLCP(console.log, {reportSoftNavs: true});
```

Note that this will change the way the first page loads are measured as the metrics for the inital URL will be finalized once the first soft nav occurs. To measure both you need to register two callbacks:

```js
import {
onCLS,
onINP,
onLCP,
} from 'https://unpkg.com/web-vitals@soft-navs/dist/web-vitals.js?module';

onCLS(doTraditionalProcessing);
onINP(doTraditionalProcessing);
onLCP(doTraditionalProcessing);

onCLS(doSoftNavProcessing, {reportSoftNavs: true});
onINP(doSoftNavProcessing, {reportSoftNavs: true});
onLCP(doSoftNavProcessing, {reportSoftNavs: true});
```

### Send the results to an analytics endpoint

The following example measures each of the Core Web Vitals metrics and reports them to a hypothetical `/analytics` endpoint, as soon as each is ready to be sent.
Expand Down Expand Up @@ -546,14 +606,28 @@ interface Metric {
* - 'prerender': for pages that were prerendered.
* - 'restore': for pages that were discarded by the browser and then
* restored by the user.
* - 'soft-navigation': for soft navigations.
*/
navigationType:
| 'navigate'
| 'reload'
| 'back-forward'
| 'back-forward-cache'
| 'prerender'
| 'restore';
| 'restore'
| 'soft-navigation';

/**
* The navigationId the metric happened for. This is particularly relevant for soft navigations where
* the metric may be reported for a previous URL.
*/
navigationId: number;

/**
* The navigation URL the metric happened for. This is particularly relevant for soft navigations where
* the metric may be reported for a previous URL.
*/
navigationURL?: string;
}
```

Expand Down Expand Up @@ -591,7 +665,7 @@ interface INPMetric extends Metric {
```ts
interface LCPMetric extends Metric {
name: 'LCP';
entries: LargestContentfulPaint[];
entries: (LargestContentfulPaint | InteractionContentfulPaint)[];
}
```

Expand Down Expand Up @@ -660,6 +734,7 @@ Metric-specific subclasses:
interface INPAttributionReportOpts extends AttributionReportOpts {
durationThreshold?: number;
includeProcessedEventEntries?: boolean;
reportSoftNavs?: boolean;
}
```

Expand Down Expand Up @@ -1091,9 +1166,10 @@ interface LCPAttribution {
*/
lcpResourceEntry?: PerformanceResourceTiming;
/**
* The `LargestContentfulPaint` entry corresponding to LCP.
* The `LargestContentfulPaint` entry corresponding to LCP
* (or `InteractionContentfulPaint` for soft navigations).
*/
lcpEntry?: LargestContentfulPaint;
lcpEntry?: LargestContentfulPaint | InteractionContentfulPaint;
}
```

Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "web-vitals",
"version": "5.2.0",
"version": "5.2.0-soft-navs-2",
"description": "Easily measure performance metrics in JavaScript",
"publishConfig": {
"registry": "https://registry.npmjs.org/"
Expand Down
23 changes: 19 additions & 4 deletions src/attribution/onFCP.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import {getBFCacheRestoreTime} from '../lib/bfcache.js';
import {getLoadState} from '../lib/getLoadState.js';
import {getNavigationEntry} from '../lib/getNavigationEntry.js';
import {getSoftNavigationEntry} from '../lib/softNavs.js';
import {onFCP as unattributedOnFCP} from '../onFCP.js';
import {
FCPAttribution,
Expand All @@ -26,6 +27,7 @@ import {
} from '../types.js';

const attributeFCP = (metric: FCPMetric): FCPMetricWithAttribution => {
const hardNavId = getNavigationEntry()?.navigationId || 0;
// Use a default object if no other attribution has been set.
let attribution: FCPAttribution = {
timeToFirstByte: 0,
Expand All @@ -34,13 +36,26 @@ const attributeFCP = (metric: FCPMetric): FCPMetricWithAttribution => {
};

if (metric.entries.length) {
const navigationEntry = getNavigationEntry();
let navigationEntry;
const fcpEntry = metric.entries.at(-1);

if (navigationEntry) {
const activationStart = navigationEntry.activationStart || 0;
const ttfb = Math.max(0, navigationEntry.responseStart - activationStart);
let ttfb = 0;
let softNavStart = 0;
if (!metric.navigationId || metric.navigationId === hardNavId) {
navigationEntry = getNavigationEntry();
if (navigationEntry) {
const responseStart = navigationEntry.responseStart;
const activationStart = navigationEntry.activationStart || 0;
ttfb = Math.max(0, responseStart - activationStart);
}
} else {
navigationEntry = getSoftNavigationEntry(metric.navigationId);
// Set ttfb to the SoftNav start time
softNavStart = navigationEntry ? navigationEntry.startTime : 0;
ttfb = softNavStart;
}

if (navigationEntry) {
attribution = {
timeToFirstByte: ttfb,
firstByteToFCP: metric.value - ttfb,
Expand Down
2 changes: 1 addition & 1 deletion src/attribution/onINP.ts
Original file line number Diff line number Diff line change
Expand Up @@ -436,7 +436,7 @@ export const onINP = (
};

// Start observing LoAF entries for attribution.
observe('long-animation-frame', handleLoAFEntries);
observe('long-animation-frame', handleLoAFEntries, opts);

unattributedOnINP((metric: INPMetric) => {
onReport(attributeINP(metric));
Expand Down
42 changes: 32 additions & 10 deletions src/attribution/onLCP.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
*/

import {getNavigationEntry} from '../lib/getNavigationEntry.js';
import {getSoftNavigationEntry} from '../lib/softNavs.js';
import {getSelector} from '../lib/getSelector.js';
import {initUnique} from '../lib/initUnique.js';
import {LCPEntryManager} from '../lib/LCPEntryManager.js';
Expand Down Expand Up @@ -48,10 +49,13 @@ export const onLCP = (
opts = Object.assign({}, opts);

const lcpEntryManager = initUnique(opts, LCPEntryManager);
const lcpTargetMap: WeakMap<LargestContentfulPaint, string> = new WeakMap();
const lcpTargetMap: WeakMap<
LargestContentfulPaint | InteractionContentfulPaint,
string
> = new WeakMap();

lcpEntryManager._onBeforeProcessingEntry = (
entry: LargestContentfulPaint,
entry: LargestContentfulPaint | InteractionContentfulPaint,
) => {
const node = entry.element;
if (node) {
Expand All @@ -65,6 +69,7 @@ export const onLCP = (
};

const attributeLCP = (metric: LCPMetric): LCPMetricWithAttribution => {
const hardNavId = getNavigationEntry()?.navigationId || 0;
// Use a default object if no other attribution has been set.
let attribution: LCPAttribution = {
timeToFirstByte: 0,
Expand Down Expand Up @@ -94,14 +99,30 @@ export const onLCP = (

// Get subparts from navigation entry. Do this last as occasionally
// Safari seems to fail to find a navigation entry.
const navigationEntry = getNavigationEntry();
if (navigationEntry) {
const activationStart = navigationEntry.activationStart || 0;
let navigationEntry;
let activationStart = 0;
let responseStart = 0;
let softNavStart = 0;

const ttfb = Math.max(
0,
navigationEntry.responseStart - activationStart,
);
if (!metric.navigationId || metric.navigationId === hardNavId) {
navigationEntry = getNavigationEntry();
activationStart =
navigationEntry && navigationEntry.activationStart
? navigationEntry.activationStart
: 0;
responseStart =
navigationEntry && navigationEntry.responseStart
? navigationEntry.responseStart
: 0;
} else {
navigationEntry = getSoftNavigationEntry(metric.navigationId);
// Set activationStart to the SoftNav start time
softNavStart = navigationEntry ? navigationEntry.startTime : 0;
activationStart = softNavStart;
}

if (navigationEntry) {
const ttfb = Math.max(0, responseStart - activationStart);

const lcpRequestStart = Math.max(
ttfb,
Expand All @@ -115,10 +136,11 @@ export const onLCP = (
// Cap at LCP time (videos continue downloading after LCP for example)
metric.value,
Math.max(
lcpRequestStart,
lcpRequestStart - softNavStart,
lcpResourceEntry
? lcpResourceEntry.responseEnd - activationStart
: 0,
0,
),
);

Expand Down
13 changes: 8 additions & 5 deletions src/attribution/onTTFB.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,27 +33,30 @@ const attributeTTFB = (metric: TTFBMetric): TTFBMetricWithAttribution => {
};

if (metric.entries.length) {
const navigationEntry = metric.entries[0];
// Is there a better way to check if this is a soft nav entry or not?
// Refuses to build without this as soft navs don't have activationStart
const navigationEntry = <PerformanceNavigationTiming>metric.entries[0];

const activationStart = navigationEntry.activationStart || 0;

// Measure from workerStart or fetchStart so any service worker startup
// time is included in cacheDuration (which also includes other sw time
// anyway, that cannot be accurately split out cross-browser).
const waitEnd = Math.max(
(navigationEntry.workerStart || navigationEntry.fetchStart) -
(navigationEntry.workerStart || navigationEntry.fetchStart || 0) -
activationStart,
0,
);
const dnsStart = Math.max(
navigationEntry.domainLookupStart - activationStart,
navigationEntry.domainLookupStart - activationStart || 0,
0,
);
const connectStart = Math.max(
navigationEntry.connectStart - activationStart,
navigationEntry.connectStart - activationStart || 0,
0,
);
const connectEnd = Math.max(
navigationEntry.connectEnd - activationStart,
navigationEntry.connectEnd - activationStart || 0,
0,
);

Expand Down
6 changes: 4 additions & 2 deletions src/lib/LCPEntryManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,11 @@
*/

export class LCPEntryManager {
_onBeforeProcessingEntry?: (entry: LargestContentfulPaint) => void;
_onBeforeProcessingEntry?: (
entry: LargestContentfulPaint | InteractionContentfulPaint,
) => void;

_processEntry(entry: LargestContentfulPaint) {
_processEntry(entry: LargestContentfulPaint | InteractionContentfulPaint) {
this._onBeforeProcessingEntry?.(entry);
}
}
21 changes: 10 additions & 11 deletions src/lib/getLoadState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,22 +23,21 @@ export const getLoadState = (timestamp: number): LoadState => {
// since the timestamp has to be the current time or earlier.
return 'loading';
}
const navigationEntry = getNavigationEntry();
if (navigationEntry) {
if (timestamp < navigationEntry.domInteractive) {

const hardNavEntry = getNavigationEntry();
if (hardNavEntry) {
if (timestamp < hardNavEntry.domInteractive) {
return 'loading';
}
if (
navigationEntry.domContentLoadedEventStart === 0 ||
timestamp < navigationEntry.domContentLoadedEventStart
} else if (
hardNavEntry.domContentLoadedEventStart === 0 ||
timestamp < hardNavEntry.domContentLoadedEventStart
) {
// If the `domContentLoadedEventStart` timestamp has not yet been
// set, or if the given timestamp is less than that value.
return 'dom-interactive';
}
if (
navigationEntry.domComplete === 0 ||
timestamp < navigationEntry.domComplete
} else if (
hardNavEntry.domComplete === 0 ||
timestamp < hardNavEntry.domComplete
) {
// If the `domComplete` timestamp has not yet been
// set, or if the given timestamp is less than that value.
Expand Down
5 changes: 4 additions & 1 deletion src/lib/getVisibilityWatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,10 @@ const onVisibilityUpdate = (event: Event) => {
}
};

export const getVisibilityWatcher = () => {
export const getVisibilityWatcher = (reset = false) => {
if (reset) {
firstHiddenTime = Infinity;
}
if (firstHiddenTime < 0) {
// Check if we have a previous hidden `visibility-state` performance entry.
const activationStart = getActivationStart();
Expand Down
Loading
Loading