CLS and bfcache: The Page-Experience Signals Teams Forget
A new cookie banner goes live on Friday. By Monday, your CLS score in Search Console has moved from 0.08 to 0.31 across your top landing pages. Nobody connected the two events because nobody was watching CLS — it’s not in the weekly metrics review, it’s not on the dashboard, and it’s not something the engineer who deployed the banner was thinking about.
That is the typical CLS incident: caused by content injection, discovered weeks later through a ranking drop or a Search Console alert, and retroactively traced to a change that seemed unrelated to performance at the time. The back/forward cache eligibility problem is even quieter — it doesn’t show up in CWV reports at all, and most teams have never run a bfcache audit on any of their pages.
Both signals are fixable once you understand what breaks them.
CLS: What It Actually Measures
Cumulative Layout Shift measures unexpected visual movement of elements after the page begins rendering. A layout shift is unexpected if it wasn’t triggered by a user interaction within 500ms before the shift (scrolling, tapping, and typing are excluded from the score). The score for a single shift is the product of two fractions:
Impact fraction: the proportion of the viewport area occupied by the elements that moved. If a heading and three paragraph blocks shift, and together they cover 40% of the visible screen, the impact fraction is 0.40.
Distance fraction: how far the shifted elements moved, measured as a proportion of the viewport height. If those elements moved down 20% of the screen height, the distance fraction is 0.20.
The layout shift score for this event: 0.40 × 0.20 = 0.08.
CLS is not a single snapshot — it accumulates across the page session. Chrome groups shifts into one-second windows with a 500ms gap between windows, and reports the largest single window. The Google threshold for a “good” score is 0.1 at the 75th percentile of field users (CrUX). Above 0.25 is “poor.”
The practical implication: one bad shift can push the entire session over the threshold, even if the rest of the page is perfectly stable. The cookie banner injected 800ms after paint is often that one bad shift.
What Causes Layout Shift
Images and embeds without explicit dimensions. The browser cannot reserve layout space for an image before it loads unless it knows the dimensions. An <img> without width and height attributes (or an aspect-ratio CSS rule) will cause surrounding content to jump when the image loads. This has been a known issue for years, yet it’s still the cause of CLS on a large fraction of audited pages. The fix is explicit width and height attributes on every <img> element, or aspect-ratio: 16/9 (or the correct ratio) in CSS when dimensions vary. Both tell the browser to reserve the space during initial layout.
Ads and dynamically-injected iframes. Ad slots are particularly bad for CLS because their dimensions are determined at auction time, not at page load. A slot that’s designed for a 250×250 banner receives a 970×250 leaderboard, the surrounding content reflows, and the shift is recorded against your domain even though the ad network caused it. The fix is to reserve the maximum expected ad slot size using a container <div> with explicit min-height. The ad can then load into that reserved space without shifting anything.
Web fonts causing FOUT and layout reflow. When a browser swaps from a fallback system font to a loaded web font, the character widths typically differ enough to cause text reflow. If the reflowing text is above the fold, this reflow registers as layout shift. font-display: swap reduces FOIT (invisible text) but can worsen FOUT-related CLS. The better approach for CLS is font-display: optional — which skips the web font on first load if it’s not cached — or using CSS size-adjust, ascent-override, descent-override, and line-gap-override to create a fallback font that matches the web font’s metrics as closely as possible. The @font-face override technique is underused and often eliminates font-swap CLS entirely.
Late-injected banners and consent overlays. Any element injected into the page after initial paint that displaces existing content causes shift. Cookie consent banners, live-chat widgets, subscription popups, and announcement bars are common offenders. The fix is to reserve the space at render time, even if the banner isn’t shown yet. A min-height container at the top of the page that holds the banner slot keeps other content from shifting when the banner appears.
Dynamically added content above the current scroll position. “Load more” carousels, infinite scroll implementations, and live-updating feeds that prepend content above the user’s current scroll position will shift everything the user is reading downward. This is a UX failure as much as a CLS failure. The fix is to either append content below (never above the viewport) or to use the @scroll-anchoring behavior that modern browsers implement automatically — but only if you haven’t disabled it with overflow-anchor: none.
CSS animations that move elements using non-transform properties. Animating top, left, margin, or width triggers layout recalculation. If these animated elements are in the viewport during animation, each frame can register as a layout shift. transform: translateY() and transform: translateX() are composited off the main thread and do not cause layout shift — use them for any animation that moves elements in the viewport. This is also a performance win independent of CLS: composited animations run at 60fps without main-thread involvement.
Diagnosing CLS in Practice
Chrome DevTools’ Performance panel shows layout shift events as purple bars in the Experience row. Clicking a shift event shows the affected elements, their before and after positions, and the shift’s contribution to the cumulative score. This is faster than attempting to reproduce CLS manually.
The PerformanceObserver API with type: 'layout-shift' provides programmatic access to shift events, including hadRecentInput (whether a user interaction preceded the shift) and sources (the elements that moved). Running this observer during a page session and logging high-scoring shifts to your analytics pipeline gives you field data on which elements are actually causing CLS for real users — not just the lab session you happened to profile.
The distinction between lab CLS (Lighthouse) and field CLS (CrUX) is significant here. Lab CLS captures shifts that occur during the simulated page load. Field CLS captures shifts across the entire user session, including shifts caused by interactions, late-loading ads, and scroll-triggered content. A page with good lab CLS but poor field CLS usually has a session-phase shift problem — something happening after initial load in response to user behavior.
bfcache: The Instant-Load Win You’re Probably Blocking
The back/forward cache is a browser mechanism that preserves a complete snapshot of a page — JavaScript heap, DOM state, scroll position — when the user navigates away from it. When the user presses the browser back button, instead of re-requesting and re-rendering the page, the browser restores the snapshot. The restoration is nearly instantaneous, typically under 100ms.
For pages where back-navigation is a normal part of the user flow — search results pages, product listing pages, articles in a feed — bfcache eligibility is a direct user experience win. Users get instant back-navigation instead of a full page reload. The downstream effect is higher engagement and longer session depth, which are behavioral signals that correlate with ranking.
The problem is that many pages are bfcache-ineligible because of patterns that seem unrelated to page navigation.
unload event listeners. The unload event has historically been used for analytics pings, session cleanup, and “are you sure you want to leave?” dialogs. Browsers cannot cache a page that has an active unload listener because the unload event fires when the page is being destroyed — which bfcache specifically prevents. The fix is to replace unload with pagehide (which fires but allows caching when event.persisted is true) or visibilitychange. The unload event is effectively deprecated for most use cases; dropping it restores bfcache eligibility and often removes a blocking navigation delay.
Cache-Control: no-store. If your server response includes Cache-Control: no-store, the browser will not cache the page in bfcache. This directive is appropriate for pages with highly sensitive data (banking, medical records), but it is often applied broadly — to entire applications via a default middleware configuration — without considering the performance cost. Audit which pages actually require no-store and remove it from pages where it’s applied by default rather than by intent.
Open WebSocket connections at navigation time. Pages that hold an open WebSocket connection when the user navigates away cannot be restored from bfcache because WebSocket connections are persistent and their state is entangled with the page. The fix is to close WebSocket connections on pagehide and re-open them on pageshow if the page is restored from bfcache (event.persisted === true). This pattern is standard in well-implemented real-time web applications.
window.opener references. Pages opened with target="_blank" that retain a window.opener reference to the parent are bfcache-ineligible in some browser implementations. Adding rel="noopener noreferrer" to cross-origin target="_blank" links is already best practice for security reasons; it also clears a potential bfcache blocker.
Unsaved form inputs in some browsers. Some browsers won’t cache pages with unsaved <input> changes to prevent data loss on restoration. This is browser-specific behavior and generally not something you can change, but it’s worth knowing when diagnosing why a form page fails the bfcache audit.
Testing bfcache Eligibility in Chrome DevTools
Chrome DevTools provides a direct bfcache audit in the Application panel under “Back/forward cache.” Running the test navigates away from and back to the page and reports either “Successfully served from back/forward cache” or a list of specific blocking reasons.
The blocking reasons are categorized: some are browser-controlled (you cannot fix them), some are page-controlled (you can fix them), and some are embed-controlled (caused by third-party frames). Focus on the page-controlled reasons first — unload handlers and no-store headers are almost always page-controlled and fixable.
VisibilityIQ’s audit engine flags Cache-Control: no-store headers and detected unload event handlers as part of its performance check suite, alongside the missing image dimension checks that drive CLS. The combination means you get a single report covering both the shift-causing patterns and the caching-blocking patterns rather than having to check them in separate tools.
The render parity audit is the adjacent check: whether the raw HTML and client-rendered DOM produce the same layout and element positions. Pages where JavaScript changes the above-fold layout after hydration often have both CLS problems (the reflow registers as shift) and render parity problems (Googlebot sees different content than real users). The two issues have the same root cause — client-side DOM manipulation changing above-fold layout — and fixing one usually fixes both.
The Practical Checklist
For CLS: audit every above-fold image for explicit width and height attributes. Reserve ad slot space with min-height containers. Replace top/left animations with transform. Move cookie banners and consent overlays to reserved space rather than injecting them after paint. Run a full-session CLS recording via PerformanceObserver to catch session-phase shifts that Lighthouse misses.
For bfcache: search your codebase for addEventListener('unload' and window.onunload — replace with pagehide. Audit your Cache-Control headers and remove no-store from pages that don’t require it. Close WebSocket connections on pagehide and restore on pageshow. Run the Chrome DevTools bfcache test on your highest-traffic pages and work through each blocking reason it surfaces.
Neither fix is architecturally complex. Both are work that slips through because they’re not in anyone’s standard deploy checklist and they don’t show up in the metrics that get reviewed weekly. The INP replacement for FID follows the same pattern — a metric that matters for ranking and user experience but only gets attention when the score drops. Proactive monitoring of all three Core Web Vitals plus bfcache eligibility is cheaper than reactive diagnosis after a Search Console alert.
For the upstream LCP optimization that completes the Core Web Vitals picture, LCP optimization covers the loading-sequence diagnosis in the same depth applied here to CLS and bfcache.