EponwebPractical guides to web development and technology
Performance Optimization

Why Is Your CLS Score High Even with Fixed-Size Images?

You set width and height on every image, yet your Core Web Vitals report still flags Cumulative Layout Shift as poor. This investigation exposes font swapping, aggressive ad injection, and dynamic widget rendering as the silent killers of your layout stability.

Mariana Costa
Mariana CostaPrincipal Backend Architect8 min read
Editorial image illustrating Why Is Your CLS Score High Even with Fixed-Size Images?

I recently audited a high-traffic e-commerce platform for a client here in São Paulo. Their frontend team was baffled. They had religiously added width and height attributes to every <img> tag, implemented aspect-ratio in CSS for all video containers, and switched to a next-gen format strategy. Despite these textbook optimizations, their Real User Monitoring (RUM) data showed a 75th percentile Cumulative Layout Shift (CLS) of 0.28—a failure by modern Core Web Vitals standards.

The team assumed images were the sole architects of layout instability. They were wrong. While images account for a significant portion of layout shifts, they are not the only elements capable of pushing content around. The browser renders the DOM in order, and when an element appearing late in the document flow occupies more space than initially anticipated, everything below it shuffles. If you have already optimized your media assets but your score remains red, the culprit is likely hiding in your typography stack, your third-party scripts, or your dynamic content injection logic.

The Invisible Villain: Web Font Loading Strategies

The most insidious cause of layout shifts often stems from text. When a browser initially paints a page, it typically uses a fallback font—like Arial or Times New Roman—while the custom web font is still downloading. Once the web font loads, the browser swaps the fallback text for the new typeface. This process, known as the Flash of Invisible Text (FOIT) or Flash of Unstyled Text (FOUT), frequently results in a layout shift because the two fonts rarely share identical metrics.

Consider the difference in line-height and character width between a standard system sans-serif and a heavy-weight web font like Montserrat or Inter. A headline that is 400 pixels wide with the fallback font might expand to 420 pixels with the custom font. If that headline sits above a call-to-action button, the button moves right. If the line-height increases, the button moves down. On a mobile device with a narrow viewport, a word reflow caused by a wider font character can push an entire paragraph down by thirty or forty pixels.

Many developers implement font-display: swap to ensure text is visible immediately. While this improves First Contentful Paint (FCP) and Largest Contentful Paint (LCP), it is devastating for CLS because it guarantees the swap will happen. To mitigate this, we must reserve space for the text. Using font-display: optional is often a better architectural choice for body copy, as it allows the browser to decide whether the swap is worth the instability. Furthermore, you can use the font-size-adjust CSS property to normalize the x-height across fonts, reducing the vertical jump during a swap.

Smaller font files load faster, reducing the window of opportunity for a shift. If you are still serving uncompressed WOFF2 files, you are extending the duration of the layout instability. While compression alone isn't a silver bullet for a bloated bundle, reducing the latency of font delivery is a critical step in stopping the swap from happening after the user has already started reading. You might think you have solved the issue by deferring non-critical CSS, but if your web fonts are blocking render or arriving late, they will undo your stability work.

Photographic detail related to Why Is Your CLS Score High Even with Fixed-Size Images?

Do You Have Control Over Third-Party Ad Injection?

If you run a media site or a blog reliant on advertising, third-party ad scripts are likely the primary source of your layout instability. Ad networks often inject iframes or divs into the DOM via JavaScript. If the ad server takes too long to respond, the container remains empty, potentially collapsing to zero height. When the creative finally loads, it snaps open, pushing the footer or the article content down abruptly.

This is a classic violation of the principle of least privilege. We often grant ad scripts full reign to modify the DOM structure without pre-allocating space for them. The common fix involves styling the ad container with a min-height. If you know that a leaderboard ad is typically 90 pixels high, setting the container to min-height: 90px ensures that the space is reserved regardless of whether the ad loads instantly or after three seconds.

However, this is tricky with responsive ads that change size based on the viewport. A 300x250 ad on desktop might turn into a 320x50 banner on mobile. If your backend only renders a single container slot, you might reserve 250 pixels vertically on mobile, leaving a massive white gap if the mobile banner is only 50 pixels tall. This isn't a shift, but it is poor UX. The architectural solution here is to query the viewport server-side or via edge computing and render the container with the exact dimensions of the expected creative for that specific device class, falling back to the smallest common denominator if the size is unknown.

Native lazy loading helps with images, but it can complicate ad layouts. If you use native lazy loading on an ad container that is below the fold, you reserve space immediately but the content doesn't load until the user scrolls near. This is generally good for CLS because the space is reserved. However, if you use a JavaScript-based Intersection Observer for lazy loading, you must ensure that the placeholder element has explicit styling immediately upon page load. Any delay in injecting the placeholder styles—even a few milliseconds—can cause a reflow when the observer finally fires.

Dynamic Content Insertion Breaks the Flow

Modern web applications are dynamic. We love to inject content—recommended articles, newsletter signup forms, or notification toasts—without a full page refresh. A common pattern I see in Single Page Applications (SPAs) is the "Load More" button at the bottom of a feed. When clicked, JavaScript appends ten new items to the list. If the network request is slow, the user might click again, or scroll down, and suddenly the content appears, pushing the footer out of view.

This is a layout shift. The user expected the footer to be there; it moved.

To fix this, we must treat dynamic insertions with the same rigor as initial page loads. If you are appending content, you should ideally inject a skeleton loader of the exact same dimensions as the content being fetched before the fetch begins. This "reserves" the visual space. When the data arrives, you replace the skeleton with the actual data. The dimensions remain constant, so the layout stays stable.

Consider the impact of chat widgets or cookie consent banners. These often drop down from the top or pop up from the bottom. If they use position: fixed or absolute, they generally do not affect CLS because they are removed from the normal document flow. However, many poorly implemented banners actually push the body content down to make room for the header. If that banner loads 1.5 seconds after the page starts, the entire viewport shifts down. The user, who was just about to click a link, suddenly clicks the background instead. The fix is simple but often missed: render the banner HTML in the initial server response, hidden via CSS if necessary, so the space is allocated from millisecond zero.

Implementing a Rollback Strategy for Sudden Shifts

Backend architecture plays a pivotal role in disaster recovery for performance. Imagine a scenario where a new ad partner is integrated, and their script causes a CLS spike from 0.05 to 0.4 across your entire user base. This happens. Since the ad script is likely injected via a tag manager or a backend configuration, you need a mechanism to disable it instantly without deploying new code.

This is where feature flags become essential. Instead of hardcoding ad network IDs or widget injection points into your templates, retrieve them from a configuration service that can be updated in real-time. If RUM alerts you to a CLS degradation, you should be able to toggle the offending component off via an API call or a dashboard. The rollback should result in the page rendering without that specific dynamic element, returning the layout to a known stable state.

Furthermore, we should implement strict Content Security Policies (CSP) that limit which domains can inject resources. While CSP is primarily a security measure, it prevents unauthorized or rogue scripts from modifying the DOM and causing unexpected shifts. By reducing the attack surface of your DOM, you reduce the variables that can cause instability.

For legacy applications, such as an older Angular monolith, cleaning up every dynamic insertion can be a massive undertaking. When refactoring isn't immediately possible, isolating the dynamic parts of the page into specific containers with reserved heights is a practical stopgap. It might not look perfect, but a box with fixed dimensions and a scrollbar is better for UX and CLS than a page that jumps unpredictably.

Predictability Over Speed

The pursuit of high Core Web Vitals scores often leads developers to obsess over speed—how fast can we get this pixel on the screen? However, CLS is not a speed metric; it is a stability metric. A slow-loading page that never jumps is technically better for CLS than a fast-loading page that rearranges itself three times.

The fundamental issue with high CLS scores, despite fixed images, is a lack of deterministic geometry. We treat the web page as a fluid stream of data, but browsers need a rigid grid to render without shifting. Whether it is a web font downloading, an ad bid completing, or a social media widget embedding, we are allowing asynchronous processes to dictate the geometry of our synchronous layout.

The solution involves a shift in mindset. We must define the layout boundaries in stone before we request the content to fill them. Reserving space for text, ads, and dynamic widgets is not "wasted" space; it is necessary architectural scaffolding. By treating layout stability as a backend concern—configuring dimensions at the data layer and enforcing strict policies on third-party scripts—we can eliminate the jumpiness that plagues modern web experiences. The goal for 2026 and beyond is not just to load faster, but to become immutable.

Read next