Vishnu's Pages

Things Learned: Cumulative Layout Shift (CLS)

While I was building April⋅SSG, I encountered the term "Cumulative Layout Shift" (CLS). It's one of the three Core Web Vitals, which are metrics Google uses to measure the user experience of a webpage. Specifically, CLS tracks how much a page's layout unexpectedly shifts while it's loading.

The CLS value ranges from 0 to 1. To provide a good user experience, a CLS score below 0.1 is recommended.

Discovering the problem

After hosting this blog (which is built with April⋅SSG), Cloudflare began reporting a high volume of CLS issues—affecting around 14% of total page loads. Here's a screenshot from Cloudflare:

Cloudflare screenshot showing high volume of poor CLS

I also checked using the "Performance" tab in Google Chrome Dev Tools, which consistently reported high CLS values:

Screenshot showing CLS of 0.61

The investigation

I started investigating and found that high CLS can occur due to several reasons:

In my case, the content is completely static, and I only use system UI fonts. Still, I was seeing layout shifts, even on pages without images, resulting in CLS values as high as 0.61.

Exploring further, I figured out that if the browser encounters an unknown HTML element, it waits for the CSS styles to load before rendering it. This can lead to a repaint and layout shifts if the styles alter the element's size or position.

The first fix: back to basics

In my templates, I was using a couple of custom HTML elements I made up: <main-list-date> and <date>. My idea was to keep the template cleaner by using fewer classes, and also just to try out using custom tags. I then used CSS to style them. Here's what the code looked like:

<ul class="main-list">
    {{#each items}}
    <li><main-list-date>{{ this.date }}</main-list-date> — <a href="{{ this.path }}">{{ this.title }}</a></li>
    {{/each}}
</ul>

<!-- and -->

<article>
    <h1>{{ title }}</h1>
    <date>{{ date }}</date>
    {{{ content }}}
</article>

Because <main-list-date> and <date> aren't standard HTML tags, the browser doesn't know how to display them right away, or how much space they'll take up. So, it waits for the CSS file to load. Once the CSS is loaded, the browser figures out how these custom tags should look and might have to redraw them with their proper styles and sizes. This redrawing part is what was causing the layout to shift.

To fix this, I switched back to using standard HTML elements in the templates:

<ul class="main-list">
    {{#each items}}
    <li><time class="main-list-date" datetime="{{this.date}}">{{ this.date }}</time> — <a href="{{ this.path }}">{{ this.title }}</a></li>
    {{/each}}
</ul>

<!-- and -->

<article>
    <h1>{{ title }}</h1>
    <time datetime="{{ date }}">{{ date }}</time>
    {{{ content }}}
</article>

This change did lower the CLS value, but it still spiked sometimes, meaning layout shifts were still happening.

Now, a little layout shift isn't the end of the world for a personal blog where people are mostly just reading. But because this was also a chance to make April⋅SSG better, I really wanted to get it just right. It was a good learning experience, and to be honest, I was curious to see if I could actually hit a CLS of zero. So, that became my new goal.

Pushing further: Zero CLS

I then looked at Bear Blog ʕ•ᴥ•ʔ, one of my inspirations for building a simple static site generator. Using the Google Chrome Dev Tools Performance tab, I saw that Bear Blog has a CLS value of 0 on most pages. A key reason for this is that it injects minified CSS directly into the page itself. This makes each page self-contained and ready to render as soon as it's received from the server, without waiting for an external CSS file. Many lightweight websites use self-contained pages.

This self-contained nature is a quality I find very important for static site blogs: every page should load in a snap and render correctly on any device, even on slow networks.

So, I updated the April⋅SSG build script to minify the CSS and inject it into every page, instead of linking it via a <link ...> tag in the header. This not only reduced chained network requests but also eliminated the delay of waiting for an external CSS file before the page could be fully rendered.

Pages with images

To further eliminate CLS on pages with images, it's essential to specify the width and height attributes for each image. To make this easier, I've updated the April⋅SSG build script to allow specifying image dimensions directly in the Markdown. Here's how it works:

![Sample Image](/path/to/image.jpg "Some title 1200x800")
![Sample Image](/path/to/image.jpg "1200x800 Some title")
![Sample Image](/path/to/image.jpg "1200x800")

April⋅SSG parses these dimensions and automatically adds them as width and height attributes in the final <img> tag. This ensures the browser knows the image's size upfront, preventing any layout shifts when the image loads.

The victory

With the custom elements replaced by standard HTML tags, CSS minified and embedded directly into each page, and image dimensions explicitly defined, every page now loads swiftly and achieves a perfect CLS score of 0. Here are the results after implementing these optimizations:

Screenshot showing CLS of 0

What I learned

This was a fantastic learning experience involving hours of debugging, exploring, figuring things out, fixing them, and finally, the satisfaction of solving the problem. It reinforces what I often tell people: "learn by doing, not just by reading."

Further reading