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:
I also checked using the "Performance" tab in Google Chrome Dev Tools, which consistently reported high CLS values:
The investigation
I started investigating and found that high CLS can occur due to several reasons:
- Images without dimensions: If images don't have specified
width
,height
, oraspect-ratio
, the browser doesn't know how much space to reserve for them before they load. When an image finally loads, it can push other content down, causing a layout shift. - Dynamically loaded content: If content is loaded dynamically (e.g., showing a small progress indicator first, then replacing it with larger data), the layout can shift to accommodate the new, larger content.
- Web fonts, scripts, or ads: These resources often load after the initial page content. If they cause changes to the layout once loaded, it contributes to CLS.
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:



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:
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
- Learn more about CLS: https://web.dev/articles/cls↗
- Learn about the other Web Vitals: https://web.dev/articles/defining-core-web-vitals-thresholds↗