Home  > Blog  > Evolving How CLS is Measured

Evolving How CLS is Measured

Dave Smart
Dave Smart
// January 26, 2021
Interesting New Cumulative Layout Shift Proposals

Potential Changes in How Cumulative Layout Shift is Measured

Update - maximum session window with 1 second gap, capped at 5 seconds is the new chosen one, I've updated My Bookmarklet & added it to my new node CLI script, that lets to test a list of URLs from a csv and captures a screencast video and the CWV metrics Get massWebVitals from Github

Annie Sullivan & Michal Mocny published an article on web.dev yesterday (25th Jan, 2021), Feedback wanted: The road to a better layout shift metric for long-lived pages. Go read it if you haven't, or this won't make sense!

However, I wanted to add a very brief couple of thoughts here, and I have thrown together a quick bookmarklet to help you evaluate the changes they are looking at.

CLS as it Stands.

There are some clear problem points with how CLS is measured in its current format. Especially for single page applications. As they are, as far as the browser is concerned, one single html page, they just have a very long page life (basically all the time spent in the SPA).

This means that although the visitor is seeing different 'pages' when they click on links in your SPA, these really are soft navigations, JavaScript is populating the DOM with the new content, so the browser doesn't know that this is a new navigation.

This means that the Web Vitals metrics can be an amalgam of all the pages a user visits in a session. With Largest Contentful Paint & First Input Delay, the issue is one of being blind, the metrics will mostly be taken for the initial interaction (the page the user lands on) and not subsequent visits. Not great, but not 'harmful'

Because CLS is gathered across the whole lifecycle of a page though, this metric can end up a sum of shifts across the whole sessions, attributed to the initial visits.

Traditional, none SPA site
URL CLS Reported
/a 0.1 0.1
/b 0.2 0.2
/c 0.1 0.1
Same Session, SPA site
URL CLS Reported
/a 0.1 0.4
/b 0.2 none
/c 0.1 none

Why not just measure on the soft navigations too?

This seems like the quick solution, and probably is the ultimate, long term goal I.M.H.O. but... How you acheive that is not trival. There are many different frameworks and custom spun solutions, how would you capture them all?

You could, perhaps, observe the history API, but pushState & replaceState, whilst commonly used for routing in SPAs, aren't universally used for this. Leaning on this might cause more issues than it solves.

Alternative Methods

The studies and the resulting proposals Annie & Michal detail in the article are some very rational ways to look at this from a different, user perception angle. I think some of them have great potential, and I love that they are so open and keen to get involvement and feedback! (You are going to offer useful feedback, not just moan about it on the internet, aren't you?)

I'll not rehash the proposals, I would do a poor job compared to the article, but I do think there's nothing like seeing it in practice to take it from the abstract to the real world, actually seeing what the different methods score is a big step into advocating for or against any given approach.

The article contains some great snippets of JavaScript, and a link to a forked version of the web-vitals library, I have also very quickly hashed up a bookmarklet to show the scores together.

Naturally, this requires chrome, drag the below button to your bookmarks, then click it when you land on the SPA you want to compare metrics on.

CLS Method Comparison

If you can't drag and drop for some reason, here's the code, and you can follow the guidance in my Simple SEO Related Chrome Bookmarklets post to install it.


    javascript: (function () {
        function sendToHolder({
            name,
            delta,
            verdict
        }) {
            var holder = document.getElementById('webVitalsHolder');
            if (typeof (holder) === 'undefined' || holder === null) {
                var holder = document.createElement('div');
                holder.id = 'webVitalsHolder';
                holder.style.position = 'fixed';
                holder.style.bottom = 0;
                holder.style.left = 0;
                holder.style.margin = '5px';
                holder.style.padding = '5px';
                holder.style.fontFamily = 'monospace';
                holder.style.fontSize = '1.5rem';
                holder.style.backgroundColor = '#fff';
                holder.style.color = '#000';
                holder.style.border = '1px solid #000';
                holder.style.borderRadius = '0.5rem';
                holder.style.boxShadow = '0 25px 50px -12px rgba(0,0,0,.25)';
                holder.style.zIndex = '999999';
                document.body.appendChild(holder);
            }
            var element = document.getElementById('webVital-' + name);
            if (typeof (element) !== 'undefined' && element !== null) {
                if (verdict === 0) {
                    element.style.color = '#0cce6b';
                } else if (verdict === 1) {
                    element.style.color = '#ffa400';
                } else if (verdict === 2) {
                    element.style.color = '#ff4e42';
                }
                element.innerText = ` ${name}: ${delta}`;
            } else {
                var extractionSpan = document.createElement('span');
                extractionSpan.id = 'webVital-' + name;
                if (verdict === 0) {
                    extractionSpan.style.color = '#0cce6b';
                } else if (verdict === 1) {
                    extractionSpan.style.color = '#ffa400';
                } else if (verdict === 2) {
                    extractionSpan.style.color = '#ff4e42';
                }
                extractionSpan.innerText = ` ${name}: ${delta}`;
                holder.appendChild(extractionSpan);
            }
        }
        var CLS = 0;
        var aSeGap5s = 0;
        var mSGap1s = 0;
        var mSGap1sL5 = 0;
        var mSlide1s = 0;
        var mSlide300ms = 0;
    
        sendToHolder({
            name: 'CLS',
            delta: CLS.toFixed(4),
            verdict: 0
        });
        sendToHolder({
            name: 'aSeGap5s',
            delta: aSeGap5s.toFixed(4),
            verdict: 0
        });
        sendToHolder({
            name: 'mSGap1s',
            delta: mSGap1s.toFixed(4),
            verdict: 0
        });
        sendToHolder({
            name: 'mSGap1sL5',
            delta: mSGap1sL5.toFixed(4),
            verdict: 0
        });
        sendToHolder({
            name: 'mSlide1s',
            delta: mSlide1s.toFixed(4),
            verdict: 0
        });
        sendToHolder({
            name: 'mSlide300ms',
            delta: mSlide300ms.toFixed(4),
            verdict: 0
        });
        var firstHiddenTime = document.visibilityState === 'hidden' ? 0 : Infinity;
        document.addEventListener('visibilitychange', (event) => {
            firstHiddenTime = Math.min(firstHiddenTime, event.timeStamp);
        }, {
            once: true
        });
        new PerformanceObserver(list => {
            list.getEntries().forEach(entry => {
                if (entry.hadRecentInput) return;
                    const elist = entry.sources;
                CLS += parseFloat(entry.value);
                var ver = 0;
                if (CLS > 0.1 && CLS <= 0.25) {
                    ver = 1;
                }
                if (CLS > 0.25) {
                    ver = 2;
                }
                sendToHolder({
                    name: 'CLS',
                    delta: CLS.toFixed(4),
                    verdict: ver
                });
                console.log('CLS:', CLS, entry);
            });
        }).observe({
            type: 'layout-shift',
            buffered: true
        });
    
        let aSeGap5scls = 0, aSeGap5scount = 0, aSeGap5sprevTs = Number.NEGATIVE_INFINITY;
    
      new PerformanceObserver((entryList) => {
        for (const entry of entryList.getEntries()) {
          if (entry.hadRecentInput) continue;
          if (entry.startTime - aSeGap5sprevTs > 5000) aSeGap5scount++;
          aSeGap5sprevTs = entry.startTime;
          aSeGap5scls += entry.value;
          var outputCLS = aSeGap5scls/aSeGap5scount;
          var ver = 0;
                if (outputCLS > 0.1 && outputCLS <= 0.25) {
                    ver = 1;
                }
                if (outputCLS > 0.25) {
                    ver = 2;
                }
                sendToHolder({
                    name: 'aSeGap5s',
                    delta: outputCLS.toFixed(4),
                    verdict: ver
                });
          console.log('Current avg-session-gap5s value:', aSeGap5scls/aSeGap5scount, entry);
        }
      }).observe({type: 'layout-shift', buffered: true});
    
      
        let mSGap1smax = 0, mSGap1scurr = 0, mSGap1sprevTs = Number.NEGATIVE_INFINITY;
      
        new PerformanceObserver((entryList) => {
          for (const entry of entryList.getEntries()) {
            if (entry.hadRecentInput) continue;
            if (entry.startTime - mSGap1sprevTs > 1000) mSGap1scurr = 0;
            mSGap1sprevTs = entry.startTime;
            mSGap1scurr += entry.value;
            mSGap1smax = Math.max(mSGap1smax, mSGap1scurr);
            var ver = 0;
                if (mSGap1smax > 0.1 && mSGap1smax <= 0.25) {
                    ver = 1;
                }
                if (mSGap1smax > 0.25) {
                    ver = 2;
                }
            sendToHolder({
                name: 'mSGap1s',
                delta: mSGap1smax.toFixed(4),
                verdict: ver
            });
            console.log('Current MAX-session-gap1s value:', mSGap1smax, entry);
          }
        }).observe({type: 'layout-shift', buffered: true});
      
        let mSGap1sL5max = 0, mSGap1sL5curr = 0, mSGap1sL5firstTs = Number.NEGATIVE_INFINITY, mSGap1sL5prevTs = Number.NEGATIVE_INFINITY;
    
      new PerformanceObserver((entryList) => {
        for (const entry of entryList.getEntries()) {
          if (entry.hadRecentInput) continue;
          if (entry.startTime - mSGap1sL5firstTs > 5000 || entry.startTime - mSGap1sL5prevTs > 1000) {
            mSGap1sL5firstTs = entry.startTime;
            mSGap1sL5curr = 0;
          }
          mSGap1sL5prevTs = entry.startTime;
          mSGap1sL5curr += entry.value;
          mSGap1sL5max = Math.max(mSGap1sL5max, mSGap1sL5curr);
          var ver = 0;
                if (mSGap1sL5max > 0.1 && mSGap1sL5max <= 0.25) {
                    ver = 1;
                }
                if (mSGap1sL5max > 0.25) {
                    ver = 2;
                }
            sendToHolder({
                name: 'mSGap1sL5',
                delta: mSGap1sL5max.toFixed(4),
                verdict: ver
            });
          console.log('Current MAX-session-gap1s-limit5s value:', mSGap1sL5max, entry);
        }
      }).observe({type: 'layout-shift', buffered: true});
    
    
        let mSlide1smax = 0, mSlide1scurr = 0, mSlide1sentries = []; 
      
        new PerformanceObserver((entryList) => {
          for (const entry of entryList.getEntries()) {
            if (entry.hadRecentInput) continue;
            while (mSlide1sentries.length && entry.startTime - mSlide1sentries[0].startTime > 1000) mSlide1scurr -= mSlide1sentries.shift().value;
            mSlide1sentries.push(entry);
            mSlide1scurr += entry.value;
            mSlide1smax = Math.max(mSlide1smax, mSlide1scurr);
            var ver = 0;
                if (mSlide1smax > 0.1 && mSlide1smax <= 0.25) {
                    ver = 1;
                }
                if (mSlide1smax > 0.25) {
                    ver = 2;
                }
            sendToHolder({
                name: 'mSlide1s',
                delta: mSlide1smax.toFixed(4),
                verdict: ver
            });
            console.log('Current MAX-sliding-1s value:', mSlide1smax, entry);
          }
        }).observe({type: 'layout-shift', buffered: true});
    
        let mSlide300msmax = 0, mSlide300mscurr = 0, mSlide300msentries = []; 
    
        new PerformanceObserver((entryList) => {
          for (const entry of entryList.getEntries()) {
            if (entry.hadRecentInput) continue;
            while (mSlide300msentries.length && entry.startTime - mSlide300msentries[0].startTime > 300) mSlide300mscurr -= mSlide300msentries.shift().value;
            mSlide300msentries.push(entry);
            mSlide300mscurr += entry.value;
            mSlide300msmax = Math.max(mSlide300msmax, mSlide300mscurr);
            var ver = 0;
            if (mSlide300msmax > 0.1 && mSlide300msmax <= 0.25) {
                ver = 1;
            }
            if (mSlide300msmax > 0.25) {
                ver = 2;
            }
        sendToHolder({
            name: 'mSlide300ms',
            delta: mSlide300msmax.toFixed(4),
            verdict: ver
        });
            console.log('Current MAX-sliding-300,s value:', mSlide300msmax, entry);
          }
        }).observe({type: 'layout-shift', buffered: true});
      
        
    })();
    
    

It's fast scruffy and I'm sure you can improve it, but it works! The CLS scores will be outputted on the screen (the titles are abbreviated, but they follow the order of the article), and also to the console.

Final Quick Thought

There's room for improvement, and I'm really impressed to see that they are so open for feedback! But just because something can be better, doesn't mean it is of zero value now. CLS is, in its current form, still a valuable metric, and one we just didn't have at all not too long back. Celebrate the improvements, don't dunk on the present.

About the Author:

Dave Smart

Dave Smart

Technical SEO Consultant at Tame the Bots.