Minimising Cumulative Layout Shift (CLS) When Loading Responsive Ads

Cumulative Layout Shift (CLS) is a measure of a page's "instability". In short, it means "how much does a page move around after it starts loading?" The more a page moves around, the worse the user experience is. The classic example here is loading a page on a mobile phone, start scrolling, then see the content move around while ads are loading. If a page has a high score on the CLS metric, it will perform poorly in the Core Web Vitals metrics, ultimately leading to a lower ranking in the Google search results.

Typically the best way to avoid a CLS penalty is to explicitly reserve the size which late-loading elements will need - image dimensions being explicitly set is the most common example of this. The dimensions being set before the image loads allows the browser to reserve the appropriate space for the image, so when it later loads, there is no "jump" in rendering. This strategy is complicated when we have elements whose size we won't know until after they load.

The most common example here is Responsive Display Ads. Responsive ads are a great way for a publisher to maximise ad revenue, but do present a number of challenges to user experience, CLS, and Google ranking when not implemented correctly. This article outlines different strategies to minimise the CLS impact when working with responsive advertising.

What are Responsive Display Ads?

"Display ads" are the advertisements typically found around the content on publisher sites - MPUs (300x250px), Leaderboards (728x90px), Billboards (970x250px), Mobile Banner (320x50px) and so on. These are most commonly served via javascript tags placed on a page to connect to the Google Ad Manager platform.

Responsive ads allow a user to define different size ads to serve at different breakpoints. The idea is that a single tag may serve a Billboard on desktop, a leaderboard on tablet and an MPU on mobile, rather than having to declare separate tags for each resolution. This ultimately makes both reporting and implementation simpler to manage for deployments of large amounts of inventory on responsive websites.

Responsive Ads Overview

When there is only one ad size served for a given breakpoint, the approach to eliminating CLS is very similar to that taken with images - apply a minimum height to the ad container matching the height of the ad unit to be served. But what about the cases like the 1024x768 size in the screenshot above, where the ad could be a 750x200, or a 728x90, and we won't know until after it loads?

We have three different approaches available when dealing with responsive ads of varying sizes.

Approach 1: Do Nothing

Add no specific size reservations to an ad container, allowing Ad Manager to take whatever size it needs. This is an historically-common approach, as it supports the ability to hide empty ads slots.

Ad Manager provides a code snippet (googletag.pubads().collapseEmptyDivs();) which will remove any space assigned to ad container if no ad is returned. This was a very popular approach with publishers who may not have a high fill rate for their inventory, and wanted to avoid blank spaces on their site where ads should be, drawing attention to their poor sales.

With the advent of CLS as an important metric, this approach is actively harmful, but is included here for reference. The collapseEmptyDivs call will often be found in Ad Manager code, and needs to be removed for approach 2 or 3 to work - it will by default add an inline style "display:none" to each element, which overrides any attempt at reserving space in a document for the ad container.

Approach 2: Reserve the smallest ad size

Let's take an example of a slot at the top of a page on desktop, which can serve a 970x250 or 728x90. If we reserve 250px height for this unit, then we will see no CLS, but in cases where the 90px is served, we will see a huge amount of unused padding around the ad. This is not a good result, as the site looks broken, and there's a significant additional scroll required by the user before content is reached - both of these items increase the risk of a rising bounce rate.

Responsive Ads Overview

If we instead reserve the smallest size (90px), then in cases where there is a 728x90, we get no CLS. In cases where a 970x250 loads, we get a shift of 160px, which isn't great, but is an improvement on a shift of 250px from the first approach listed above.

This approach is ok - it's often a perfectly sensible default! But we can do better.

Approach 3: Reserve the most commonly-used size, and adapt if the ad served is smaller

The approach in step 2 will work very well if a site typically sells the smallest available ad size, rarely serving the larger size. For that site, the common case will be no CLS, with a rare burst of CLS if a larger ad is served. For sites who sell almost exclusively direct, this approach may work, but many sites have a large amount of programmatic ad network traffic being served, where they don't know who bought the slot or what campaign they'll serve, but trust that the ad network is showing the ad of the highest bidder. And the highest bidder for desktop slots typically uses larger sizes, so for sites with a high reliance on programmatic, larger sizes will be more common, with the user getting a noticeable CLS as the larger ad serves into the smaller reserved slot.

The first step here is to get an understanding of which ad sizes are most commonly served into a given slot where more than one size is available. This data can be retrieved from Ad Manager Delivery reports, or from the Ad Ops person responsible for the site. If the larger ad size is served the majority of the time, then it becomes a sensible default to use that height.

Approach 3.1: First, add the minimum height to the ad container

// ads.css
.min-h-250 {
  min-height: 250px;
.min-h-90 {
  min-height: 90px;
<!-- article.html - trigger ad unit -->
<div class="text-gray-700 text-xs uppercase">Advertisement</div>
<div id="ad-container-top-page" class="min-h-250">
        googletag.cmd.push(function() {

The above code will ensure that our ad unit has a minimum height of 250px applied. But if we're going to set the minimum height of the ad container to 250px, are we not going to be left with the large, empty vertical space when a unit 90px high serves? Enter Ad Manager's rendering callbacks!

Approach 3.2: Ad Manager rendering callbacks

When initialising Ad Manager code, it's possible to register a callback which will be triggered every time an ad request finishes rendering. This callback is triggered whether an ad request actually renders an ad or returns an empty response. We're going to hook into this event to check if the rendered size of an ad was smaller than our minimum height, and if so, remove the min-height class, allowing the ad unit container to shrink to the appropriate size. This will still trigger CLS, though if we've analysed the delivery patterns for the different ad sizes, this CLS should be seen by the minority of users, with the common case being no CLS.

// article.html
googletag.cmd.push(function() {
    // Callback fired for each ad request finishing
    googletag.pubads().addEventListener('slotRenderEnded', function(event) {
        // id of container attached to ad event
        let domId = event.slot.getSlotElementId();
        // DOM element associated with that id
        let element = document.getElementById(domId);

        // If the container for this ad has had a min-height specified,
        // and the ad is either empty or smaller than that value, remove
        // the min-height value.
        element.classList.forEach((value) => {
            if (value.match('min-h-')) {
                let minHeight = value.replace('min-h-', '');
                if (event.isEmpty || minHeight > event.size[1]) {

In the above callback:

  • If the callback tells us we’ve an empty unit, remove the min-height class. This collapses the unit, effectively acting the same as the "collapseEmptyDivs(true)" configuration option mentioned in approach 1.
  • If we have a unit to be rendered, but the height is smaller than the min-height, remove the min-height class. This allows the ad container to collapse to only take the height of the rendered unit. We get a CLS hit, but avoid large empty spaces.
  • If we have a unit to be rendered, but the height is larger than the min-height (300x600 sidebar, for example), do nothing - the smaller min-height doesn’t constrain this appearance.

Approach 3.3: Empty units

Empty units discussed in approach 1, or in the case of empty units in 3.2, cause fairly significant CLS, and can add complexity around other elements to be removed (the "Advertisement" label and background elements in place to avoid Google's 2-Click Penalty). It's recommended that any publisher adding an Ad Manager integration sets up House Ads. A House Ad campaign is one which is served whenever no other ad unit can be found, and typically consists of ads for the publisher's social channels, or other parts of the site. These units serve as the ultimate fallback, ensuring that there is no spike in CLS in the event that there's a low fill rate from the ad networks or direct sales.

Does it work?

This approach was implemented on a popular Irish news site recently. That site previously adopted the "sensible defaults" approach number 2 mentioned above, where the size reserved for each unit was that of the smallest available ad unit. In practice, CLS was being caused by:

  • Desktop 970x250 being more common than 728x90
  • Mobile mid-article ads being typically 300x250 rather than 320x50
CLS Before
CLS After

The amount of users experiencing Poor CLS fell by 64% overnight.

The caveat to this approach is that it requires constant revision. If the sales mix of ads shown changes and different sizes become more common, then we are effectively baking in a CLS for the majority of users. There is a requirement to regularly review these metrics to ensure the most common ad sizes are being optimised for.

Min-height is mentioned a lot - why not just use height?

Ad Manager will typically serve ads into the size requested, but will occasionally decide to render a slightly larger ad unit (an advertiser may upload a non-standard size which pays a lot more). If we explicitly constrain the height, then we run the risk of part of the ad being hidden, which then runs the risk of a Google Two-Click Penalty being applied to the site.

What about width?

The same techniques listed above can be used to apply minimum widths to ad units. In practice, variable widths are a less significant contributor to CLS, as ad units tend to be either on a row by themselves (top of page ads like billboards and leaderboards), or on a row within a container of fixed width (mid-article ad units). All of the techniques listed above can be applied to width also, though height will have the most significant performance impact.

PHPers Summit 2024 Speaker

International PHP Conference
Munich, November 2024

In November 2024, I'll be giving a talk at the International PHP Conference in Munich, Germany. I'll be talking about the page speed quick wins available for backend developers, along with the challenges of policing dangerous drivers, the impact of TV graphics on web design, and the times when it might be better to send your dev team snowboarding for 6 months instead of writing code!

Get your ticket now and I'll see you there!

Share This Article

Related Articles

Lazy loading background images to improve load time performance

Lazy loading of images helps to radically speed up initial page load. Rich site designs often call for background images, which can't be lazily loaded in the same way. How can we keep our designs, while optimising for a fast initial load?

Idempotency - what is it, and how can it help our Laravel APIs?

Idempotency is a critical concept to be aware of when building robust APIs, and is baked into the SDKs of companies like Stripe, Paypal, Shopify, and Amazon. But what exactly is idempotency? And how can we easily add support for it to our Laravel APIs?

Calculating rolling averages with Laravel Collections

Rolling averages are perfect for smoothing out time-series data, helping you to gain insight from noisy graphs and tables. This new package adds first-class support to Laravel Collections for rolling average calculation.