web-vitals icon indicating copy to clipboard operation
web-vitals copied to clipboard

Web Vital Metrics for Single Page Applications

Open hbpatel142 opened this issue 3 years ago • 28 comments

In React Single page Application(SPA), First Input Delay and Largest Contentful Paint are only measured once on Initial Load. Additionally, Cumulative Layout Shift does not reset to 0 throughout the session. This means CLS could reach to very high value , if user continues to navigate though logical pages like homepage -> search -> Product Listing -> Product Page -> Basket -> Checkout and so on.

Based on current implementation, pageshow event resets CLS to 0 and it also captures FID and LCP for subsequent page loads.

In React Single Page Application, Could we consider route change event for the new logical page load and reset CLS to 0. This would also enable us to capture FID and LCP on every logical page of the application.

If Web Vital Metrics would be considered for page ranking, the proposed implementation would give React SPA fair comparison.

hbpatel142 avatar Jan 22 '21 11:01 hbpatel142

Had the same question, the docs need some context for SPAs (or any app with history-based navigation)

For example, there should be a way to manually reset the values based on a trigger or event.

arpowers avatar Jan 23 '21 01:01 arpowers

I had wondered whether subsequent page loads with an SPA are even (or should be) considered wrt web-vitals? Either way I'd be interested in seeing some guidance on how to handle web-vital measurement for SPAs

fraser-m-hurley-LTK avatar Jan 25 '21 11:01 fraser-m-hurley-LTK

Unfortunately, accounting for SPA route transitions in Core Web Vitals metrics is not currently possible. There are a few primary reasons for this, both technical reasons and practical reasons.

The technical reason is that the Web APIs that measure page load specific Core Web Vitals metrics (LCP and FID) do not re-emit entries after a SPA route change occurs. For FID we could use the same polyfill we use in the case of a bfcache restore, but for LCP we could not, since route changes typically involve loading additional content, and polyfilling LCP to handle those cases would be near impossible (and it would certainly affect performance).

Also, as you point out, in the case of CLS we could reset the metric value to 0 (as we do for a bfcache restore), but this is where the practical issues come into play: what counts as an SPA route change?

  • Should every call to history.pushState() be considered a route change?
  • What about history.replaceState()?
  • What about apps that still use hash URLs for SPAs? (A lot of those still exist.)

While I understand it's probably quite easy for you to determine what should be considered a "logical page load" for your specific app. Making that determination for any given web page is pretty much impossible.

Finally, and perhaps the most challenging of the practical issues is that both Google Chrome and Google Search are creating incentives around these metrics, which means it's absolutely critical that they can't be easily gamed.

If we consider every call to history.pushState() to be a new page load, then developers would have a strong incentive to use that API, even in cases where a user wouldn't consider it a "logical page load".

For example, you could easily imagine some code like this:

// Create a new "page load" any time CLS gets close to crossing the 0.1 threshold...
If (cls > 0.09) {
  history.pushState({...}, document.title, location += '&addendum=1)
}

Similarly, developers might try calling history.pushState() during idle periods to increase the likelihood of LCP and FID being super fast.


Hopefully this helps explain why Core Web Vitals metrics currently don't consider SPA route transitions. But rest assured we are aware of this problem and looking into solutions. Here are two concrete examples:

philipwalton avatar Jan 26 '21 05:01 philipwalton

Thanks for the quick response!

Above is really good explanation on why current route change identification (within SPA) alone is not sufficient for the web vitals that must not be gamed, but at the same time very critical that it's measured accurately, since this would impact page ranking in May 2021 unless the timeline is changed.

It is really great that the team is actively looking for the solution and the official documentations around SPA have started to surface recently.

hbpatel142 avatar Jan 26 '21 10:01 hbpatel142

Thanks for the response! I would just add that or part of that to the docs for the package.

Even if vitals don't apply, there are natural questions that get asked: "how does this apply to SPAs?" A few sentences would answer that cleanly.

arpowers avatar Jan 26 '21 18:01 arpowers

FYI: we're working on some content related to SPAs for web.dev/vitals. Once that's published we can link to it from this repo.

philipwalton avatar Feb 01 '21 17:02 philipwalton

Wow, I've spent months pondering why google search console flags so many of our SPA's pages as having poor CLS but lab data (lighthouse) and performance traces (Chrome dev tools) suggest it's very good.

On first load we generally see web-vitals report 0, but with each SPA navigation we see our CLS creep up and up which is why we're seeing such crazy (and unfair?) values; all we're really garnering from CLS is how long somebody is on the site for 🤦🏻‍♂️

I think the layout shifts which are being accumulated across SPA navigations are those caused by a successful network response after navigation because, like many SPAs, on click we show a blank screen with a loading indicator while fetching the data, and then when the data arrives we render it to screen causing a layout -- if the page data being navigated to is already in memory then I've observed the CLS isn't reported, I guess because the layout is the direct result of a click handler -- https://www.paradeworld.com/

In the meantime we're going to only report the first CLS value reported by web-vitals, but it's terrifying to think google search is going to factor in CLS over the entire session and penalise SPAs offering a decent user experience?

richardscarrott avatar Mar 03 '21 20:03 richardscarrott

Hey @richardscarrott, see the post on how we plan to update CLS that I mentioned above to address your concern about how the value just keeps increasing the longer the user is on the page.

As for the the technique of showing a loading spinner and then filling in the content once the network request completes, this may still be counted as layout shifts—if it's the case that elements on the page are shifting as new content is being added.

I'd recommend updating your SPA transition logic to either:

  • Reserve the required space for all new content (if the dimensions are known) ahead of time, or
  • hide the content as it's loading (via using dipslay:none, visibility:hidden , or opacity:0) and then only show it once it's all available.

philipwalton avatar Mar 04 '21 03:03 philipwalton

Hi @philipwalton thanks for the advice.

I had a bit of a think about your last point re: hiding the content while it's loading and realised I had initially thought the layout shift was merely counting the layout of the new content rendered to screen but of course it's our footer which get's shifted further down the page.

It wasn't immediately obvious to me because we do render the footer offscreen while loading by reserving the height of the viewport; this was done specifically to prevent the footer flashing into view. e.g.

Screenshot 2021-03-04 at 09 57 27

And I guess, although unlikely imo, a user could scroll the footer into view during the loading period and then genuinely experience a layout shift. It seems layout shifts don't discriminate against things inside vs outside the viewport 🤔.

I tested with the footer hidden while loading and as you've suggested this solves the issue so we're seeing almost 0 CLS reports during the page lifecycle.

Of course, we still have the issue of the now rarer and slight layout shifts accumulating over the SPA session but I'll give that article another read and look forward to hearing about improvements on this.

UPDATE: So I had a proper read of the CLS docs and of course it does attempt to only consider layout shifts which are in the viewport, however it seems to get this wrong with our footer. Adding margin-top: 21px to our footer (20px doesn't quite cut it on all viewport sizes I tested 🤷‍♂️) prevents the CLS event from firing on SPA navigations; obv we're then betting on the user not scrolling the footer into view while loading, but will see how this goes (Side note, I feel like I'm gaming the metric a little here).

UPDATE2: So there obviously had to be a reason it was 20px specifically and I think it's because we have a negative margin on a child element in our footer to compensate for some third party styles, so although visually it didn't appear to be in the viewport it's bounding box did creep in by 20px.

Screenshot 2021-03-04 at 13 20 49

richardscarrott avatar Mar 04 '21 10:03 richardscarrott

I am just getting started with measuring of CLS and LCP for my React based SPA and came across web vitals module and this thread. I just played around with the api getCLS and getLCP for my SPA by including a call in one of my pages when the page component is loaded by JS.But I do not see CLS score or LCP score printed on console.LCP gets printed once after I interact with the page, like a simple click on the UI and no amount of route changes prints it again. CLS never gets logged at all. Is the conclusion of this particular thread that for SPAs, web-vitals module may not work as expected?Are there any steps I can perform on my SPA to have getLCP and getCLS work properly?

Midhun-Carousell avatar Mar 11 '21 06:03 Midhun-Carousell

@Midhun-Carousell

LCP gets printed once after I interact with the page, like a simple click on the UI and no amount of route changes prints it again.

This is expected. LCP is only measured at page load, and this is intentional for the reasons I mentioned above. Also, the reason you're seeing LCP logged to the console after you interact is because that is the point when LCP observation stop waiting to see if a larger element gets added to the DOM (we can't log the value before that point).

CLS never gets logged at all.

CLS is only logged when the page is backgrounded or unloaded, so if you switch tabs you should see it logged. You'll also see it logged if you check the preserve log option in devtools and reload the page (it will log CLS for the previous page).

For both LCP and CLS, if you want to log the metric value every time it changes, you can do that with the reportAllChanges option.

Is the conclusion of this particular thread that for SPAs, web-vitals module may not work as expected?

I suppose it depends on what you consider "expected", but based on what you've described, it sounds like it's working exactly as intended.

If you were expecting all the Web Vitals metrics to re-emit after a route change in an SPA, that is not expected to happen.

philipwalton avatar Mar 13 '21 20:03 philipwalton

@richardscarrott thanks for the update.

UPDATE2: So there obviously had to be a reason it was 20px specifically and I think it's because we have a negative margin on a child element in our footer to compensate for some third party styles, so although visually it didn't appear to be in the viewport it's bounding box did creep in by 20px.

This library is just collecting what the underlying Layout Instability API is reporting. If you think this behavior is confusing or wrong, I'd recommend filing an issue on the spec highlighting your use case, and it will be discussed there.

philipwalton avatar Mar 13 '21 20:03 philipwalton

@philipwalton even after the new update I can still see the same page id when I navigate btw pages in a SPA. This means that the cumulative CLS is summed to the initial page.

On a flow: search page => category page all the CLSs have the same id. Is this expected?

AdrianMuntean avatar Jul 02 '21 13:07 AdrianMuntean

Is this expected?

Yes, this is expected. Until we have more reliable ways to detect user-initiated SPA transitions (or a dedicated web APIs to measure them) then Core Web Vitals metrics will need to be attributed to the URL was that present at the time the page was loaded.

We recognize that this makes debugging quite awkward in cases like the one you described: where the problematic layout shifts occur after an SPA transition to a new URL.

If you're measuring your Core Web Vitals with an analytics tool, we recommend collecting both the URL at the time of the shift and the URL that the page was originally loaded at for debugging purposes.

philipwalton avatar Jul 02 '21 18:07 philipwalton

Just to add to this conversation, this is an email I had sent previously to the Chrome Speed team in Feb 2021:

I would like to discuss with you two scenarios, both Ajax-related, that have been affecting my website's CLS score.

.

Scenario 1:

My website / PWA uses Ajax navigation, which keeps Header/Footer and triggers pushState(), every time someone clicks a link to navigate. When a navigation happens, the URL changes, and the contents (except Header and Footer) are replaced with the new page.

I noticed that the CLS score increases because the Footer is pushed up or down, when the main contents are updated. This makes CLS increasing "forever", while my users are on my website, causing a Red/Bad CLS on my domain overall.

Any idea how I can go around that, and reset CLS score every time a user navigates?

I'd say that if, within approx. 3~5 seconds:

  • User clicked a link/button
  • pushState() happened
  • An HTTP request happened
  • DOM was updated

Then CLS could be reset, or at least "ignored".

.

Scenario 2:

Think about Comments on a Post. And users can reply to the comments (nested replies). We do something similar to Facebook/Instagram, where we show the last 3 nested comments, but you can "View X more comments". Clicking on that button, we do an Ajax request to load the previous 10 comments (between the Parent comment and the already-loaded nested comments). Since this is an async behavior, it causes CLS issue by default (as it moves the DOM below, to load the new comments above other comments in the page).

No problem, we can reserve some space on the screen as soon as the user clicks. The problem is that the system needs to know in advance how much space those comments (to be loaded) need... otherwise, if we reserve too little space, CLS will be an issue after the comments load; but if we reserve too much space, then there will be empty/blank space in between the new comments and the old comments. If we reserve some space and then remove the excess after the comments load, CLS is an issue again.

How can we make "Google/CLS" happy here? I hope you understand my position here. Our users have never complained about "layout shifting" here. It's a user action, but because it's an async request, it causes CLS in Google's view.

.

I believe a solution similar to the suggested in Scenario 1 might help, although in this case we wouldn't do pushState().

nunoperalta avatar Jul 11 '21 00:07 nunoperalta

I think I'm mostly curious about whether we should even be tracking web vitals one page transitions within SPAs. When google uses vitals scores, is it accounting for internal navigation within a site, or is it simply comparing the scores from the initial page load of each individual page as an entry point? Clearly, the entry point load is always going to have worse scores than any internal transition, as there is some framework boilerplate that you need on the initial load of a page that won't happen if you transition to that same page after initially entering the app on another page (ie, scores for going directly to example.com/about would be worse than scores computed transitioning to /about after already loading the example.com homepage, right?).

Correct me if I'm wrong, but my understanding is that google measures the vitals on the page from the entry point perspective (ie someone clicks a link from google directly to a page) and thus measuring metrics of internal page transitions are just going to conflate your aggregations to a much more favorable number than what google is actually ranking you for, no?

heyitstowler avatar Aug 03 '21 20:08 heyitstowler

To provide an update to everyone, we recently published a post on web.dev with answers to common questions about how SPA architectures affect Core Web Vitals: https://web.dev/vitals-spa-faq/

The post includes a section re: what is Google doing to address the SPA issue with some specific plans. Once there's more progress on those proposals I'll update this library to incorporate any changes here.

philipwalton avatar Sep 21 '21 22:09 philipwalton

Finally, and perhaps the most challenging of the practical issues is that both Google Chrome and Google Search are creating incentives around these metrics, which means it's absolutely critical that they can't be easily gamed.

I think the development of Web Vitals should depend on the need for web performance optimization and not be constrained by Google search, although Web Vitals is dominated by Google. I'm sorry if i caused offense.

zhouqicf avatar Apr 12 '22 12:04 zhouqicf

I do agree with @zhouqicf

I must say core web vital is cool and can help to measure whether the website is cool enough or not But because of the limitation in calculation on SPA, we may need to give up the SPA and change back to using server-side route (MPA) It's quite nonsense that because of the SEO, we need to make the user experience worse.

vip30 avatar Jan 16 '23 13:01 vip30

Please note there is experimental support of this in the soft-navs branch as detailed here: https://developer.chrome.com/blog/soft-navigations-experiment/

tunetheweb avatar Mar 06 '23 18:03 tunetheweb

I used the web Vitals extension Google browser plugin to observe SPA single page routing switching and found that it can collect changes in CLS and LCP indicators, but calling the onCLS and onLCP methods with web Vitals does not work

huanghairong2312 avatar May 12 '23 11:05 huanghairong2312

Yes we have not released a version of the extension with this experimental branch yet because it is still experimental and subject to change.

tunetheweb avatar May 12 '23 11:05 tunetheweb

Yes we have not released a version of the extension with this experimental branch yet because it is still experimental and subject to change.

I'm very sorry! I probably didn't express the issue clearly .

I installed the web Vitals plugin in Chrome browser; The name of the plugin is' web vitals extension'

the browser plugin link https://github.com/GoogleChrome/web-vitals-extension

My question is:

The browser plugin can detect SPA router change values, why can't I use web Vitals in the code to obtain these change values .

Examples:

step 1. pageload show metrics

image

step 2. SPA router change show metrics

image

huanghairong2312 avatar May 16 '23 03:05 huanghairong2312

The web vitals extension always shows the current URL when the Heads Up Display (HUD) is opened. This has not changed and was always the case. Perhaps we should change this, to make it more obvious it's based on the initial page.

LCP for an SPA is measured across all the soft navigations. However LCP is finalised when a click happens, so normally you wouldn't see an LCP change (assuming you do a click to initiate the soft navigation?). However, if there was an automatic change in SPA route, without an interaction, then yes the LCP would still continue to take the latest one if it is bigger. Chrome will also measure this as a new LCP but report it back based on the initial URL.

The experimental branch, however, "resets" LCP so it is logged on the second and subsequent SPA page even if it is not bigger. So that's why it's different.

tunetheweb avatar May 16 '23 18:05 tunetheweb

Coming here after I realize that INP and CLS are fired on window unload (and after reading web vitals faq)

Just want to add on our specific use case.

The specific issue for our specific use case:

  1. User lands on /foo/bar/*. LCP and FCP are emitted with trackingId as /foo/bar*.
  2. User navigates away with a click, lands on /whatever/:title.
  3. User closes the tab or refreshes the browser, whatever action to trigger INP and CLS metric emission. Now it is emitted with trackingId as /whatever/:title.
  4. If we want to utilize these 4 metrics to calculate our custom scoring system, then we will have conflicting trackingId.

Like many have said, would be great to see them fired as well on route (history?) change, that way we would be able to use the metrics for the same route to calculate our custom scoring system for that particular route. This is why it's ideal for all the metrics to get emitted in the same route.

ardok avatar Jun 02 '23 03:06 ardok

Unfortunately, accounting for SPA route transitions in Core Web Vitals metrics is not currently possible. There are a few primary reasons for this, both technical reasons and practical reasons.

The technical reason is that the Web APIs that measure page load specific Core Web Vitals metrics (LCP and FID) do not re-emit entries after a SPA route change occurs. For FID we could use the same polyfill we use in the case of a bfcache restore, but for LCP we could not, since route changes typically involve loading additional content, and polyfilling LCP to handle those cases would be near impossible (and it would certainly affect performance).

Also, as you point out, in the case of CLS we could reset the metric value to 0 (as we do for a bfcache restore), but this is where the practical issues come into play: what counts as an SPA route change?

  • Should every call to history.pushState() be considered a route change?
  • What about history.replaceState()?
  • What about apps that still use hash URLs for SPAs? (A lot of those still exist.)

While I understand it's probably quite easy for you to determine what should be considered a "logical page load" for your specific app. Making that determination for any given web page is pretty much impossible.

Finally, and perhaps the most challenging of the practical issues is that both Google Chrome and Google Search are creating incentives around these metrics, which means it's absolutely critical that they can't be easily gamed.

If we consider every call to history.pushState() to be a new page load, then developers would have a strong incentive to use that API, even in cases where a user wouldn't consider it a "logical page load".

For example, you could easily imagine some code like this:

// Create a new "page load" any time CLS gets close to crossing the 0.1 threshold...
If (cls > 0.09) {
  history.pushState({...}, document.title, location += '&addendum=1)
}

Similarly, developers might try calling history.pushState() during idle periods to increase the likelihood of LCP and FID being super fast.

Hopefully this helps explain why Core Web Vitals metrics currently don't consider SPA route transitions. But rest assured we are aware of this problem and looking into solutions. Here are two concrete examples:

@philipwalton Hi I was wondering is there any update on this? Is there any metrics that is accurate on SPA from the improvements? I'm particularly interested in FID and INP

ming-tee-squareup avatar Jan 05 '24 00:01 ming-tee-squareup

@philipwalton Hi I was wondering is there any update on this? Is there any metrics that is accurate on SPA from the improvements? I'm particularly interested in FID and INP

This is the most recent update: https://developer.chrome.com/docs/web-platform/soft-navigations-experiment

philipwalton avatar Jan 16 '24 18:01 philipwalton

@philipwalton thanks for you comments here, I understand why web-vitals package or chrome by itself can't identify what is a SPA page transition that should trigger a reset. Having said that, it would be amazing if the web-vitals package itself had an API that can be used when the website owner knows it's a SPA page transition and it's now time to reset cumulative metrics and re-emit FID on this page load.

I hope that makes sense to add to the existing package or create a separate package which will add this support. Thanks

ap-shahar avatar Jan 26 '24 23:01 ap-shahar