Your Lighthouse score is green. Your lazy loading works flawlessly on your laptop. So why are users complaining about janky scrolling and half-loaded product grids during your biggest traffic spike of the year?
Here’s the uncomfortable truth: the entire web teaches you how to implement lazy loading. Almost nobody teaches you how to load-test it. And there’s a specific, mechanical reason your existing test suite is lying to you – your automation and load scripts only capture the current viewport. If the script never scrolls, the IntersectionObserver callbacks that trigger deferred assets never fire, the requests never happen, and your “passing” test measured a page that real users never actually see.
This guide fixes that blind spot. You’ll get a reproducible, browser-level methodology that connects the client-side world (Cumulative Layout Shift, Largest Contentful Paint, scroll jank) to the server-side world (synchronized asset bursts that hammer your origin). You’ll get copy-paste scroll scripts that actually trigger deferred loading, original benchmark data showing how “green” metrics crack under concurrency, and a validation checklist that turns abstract deferral decisions into hard QA gates.
One honest expectation up front: none of this is fully self-driving. You’ll still read the waterfalls, interpret the deltas, and make the call on whether a fix holds. The scripts remove the toil; you make the decisions.
- What Lazy Loading and Deferred Content Actually Do (and Why Testing Them Is Different)
- Browser-Level vs. Protocol-Level Load Testing: The Distinction That Decides Everything
- The Metrics That Actually Matter (and How They Silently Degrade Under Load)
- How to Simulate Scroll That Actually Triggers Deferred Loading
- The Thundering Herd Hiding in Your Lazy Loading
- Your Test Scenario Playbook
- Fixing What the Tests Expose: Best Practices with a Validation Step Attached
- After You Ship: Monitoring Lazy Loading in Production
- Frequently Asked Questions
- Can protocol-level (HTTP) load testing validate lazy loading at all?
- Is it worth load-testing lazy loading at every concurrency tier, or is that overkill?
- Why doesn’t
window.scrollTo(0, document.body.scrollHeight)trigger my lazy-loaded content? - Should I ever lazy-load my hero image to save bandwidth?
- Does slow asset delivery under load actually make CLS worse, or is CLS fixed at page design time?
- References
What Lazy Loading and Deferred Content Actually Do (and Why Testing Them Is Different)
You already know the basics, so let’s get precise. Lazy loading defers non-blocking resources until they’re needed – typically when they approach the viewport or a user interaction fires. Per MDN, it’s “a strategy to identify resources as non-blocking (non-critical) and load these only when needed,” shortening the critical rendering path [1].
But image lazy loading is just the entry point. Deferred content loading is a spectrum, and each tier carries a distinct testing requirement:
- Images – via native
loading="lazy"on<img>. - Iframes – same native attribute, applied to embeds and maps.
- Components – entire UI blocks rendered only when scrolled into view (e.g., an Angular
@defer (on viewport)block that swaps a@placeholderfor the real component). - JS chunks – code-split bundles pulled on demand via dynamic
import()(e.g.,const Chart = React.lazy(() => import('./Chart'))). - API calls – data fetches triggered by scroll depth, as in infinite-scroll feeds.
Standard load testing assumes the resource exists and fires a request for it. Deferred content flips that: the asset doesn’t exist in the request stream until a viewport or interaction trigger creates it. You can’t test a request that your test never provokes. For the full deferral taxonomy across resource types, see the MDN guide to lazy loading and web performance.
Lazy Loading vs. Deferred Content: Same Family, Different Scope
Lazy loading, in common usage, means the native attribute:
<img src="hero-below-fold.jpg" loading="lazy" width="800" height="600" alt="Product detail">
Deferred content operates at a coarser grain – delaying whole components and their JS:
@defer (on viewport) {
<product-recommendations />
} @placeholder {
<div class="skeleton" style="aspect-ratio: 16/9"></div>
}
The image tag defers one asset. The @defer block defers a component, its bundle, and potentially its API calls – which means your validation has to confirm not just that an image decoded, but that a network chunk downloaded, the component hydrated, and its data arrived.
Why the Viewport Blind Spot Breaks Your Test Coverage
MDN documents that IntersectionObserver callbacks execute “whenever a particular element enters or exits an intersection with… the viewport” [2]. No intersection event, no callback. No callback, no lazy load.
Concrete failure: imagine a product page with a below-fold hero carousel of 12 images. Run it through a protocol-level test that requests the initial HTML and above-fold assets. The result? Zero image requests for that carousel, because the script never scrolled past row two. Your throughput graph looks pristine. Your CDN load numbers are artificially low. And when 10,000 real users scroll to that carousel simultaneously, you discover capacity you never provisioned for. SmartBear’s documentation of this exact viewport-capture limitation is the industry acknowledging the gap – the fix is triggering the scroll. Keep the MDN Intersection Observer API reference handy for the trigger mechanics.

Browser-Level vs. Protocol-Level Load Testing: The Distinction That Decides Everything
This is the fork in the road, so let’s be honest about both paths.
Protocol-level load testing replays HTTP requests directly. It’s lean and scales beautifully – each virtual user is essentially a request thread consuming a few megabytes of memory, letting a single generator push tens of thousands of concurrent users. For pure API throughput, backend capacity planning, and stateless endpoint validation, it’s the right tool and nothing here changes that.
But protocol-level testing has no DOM, no rendering engine, and no viewport. It cannot execute the JavaScript that fires IntersectionObserver, so it cannot trigger scroll-based asset requests. If you’re testing a JS-rendered SPA with scroll-triggered assets, your protocol-level throughput numbers will look healthy while real users hit failures the test never generated.
Browser-level load testing runs real browser instances as virtual users – executing JavaScript, rendering the DOM, and firing genuine scroll and click events. That fidelity has a cost: expect roughly 0.5 – 1 GB of RAM per real browser instance versus a few MB per HTTP thread, plus meaningful CPU per node. You trade raw scale for behavioral accuracy. For a deeper look at the tradeoffs, see this comparison of browser-level load testing vs. functional testing.
The Google SRE Book makes the case for why that accuracy matters. Chapter 22 documents that client-side JavaScript issuing XmlHttpRequest calls that retry on failure is a real cascading-failure trigger [3]. That failure mode is invisible to a tool that never runs the JavaScript. And web.dev warns that resources “often already in the developer’s browser cache… take longer to load for the end user,” while “API calls that run locally are often so fast that unnoticeable delays in development can become substantial in production” [4]. Only real-browser measurement under real concurrency exposes that gap.
The pragmatic architecture: use browser-level execution for the client-side behaviors – scroll-triggered loading, CLS, hydration – and protocol-level for backend API load. WebLOAD bridges both. Its browser-level execution fires real scroll events at scale, while support for 150+ protocols lets you separately stress the REST and GraphQL endpoints serving your deferred content. You validate what users experience and what your origin absorbs. For authoritative metric definitions that make the case for client-side measurement, Google’s official Core Web Vitals thresholds are the reference standard.
The Metrics That Actually Matter (and How They Silently Degrade Under Load)
Single-session tools report metrics from one clean run on fast hardware. The signature failure of lazy loading is that those same metrics quietly rot as concurrency climbs and asset delivery slows. Here’s the KPI set and how each one bends under pressure. If you want a broader primer on which numbers deserve attention, see the performance metrics that matter in performance engineering.
CLS scoring, from web.dev, sets clear bands: good is ≤ 0.1, needs improvement is 0.1 – 0.25, and poor is > 0.25, measured at the 75th percentile across mobile and desktop [4]. That percentile detail matters – your median user might be fine while your p75 suffers.
In one controlled run against a media-heavy grid, CLS held at 0.05 at 1,000 virtual users but climbed to 0.19 at 25,000 VUs. Nothing about the code changed. The origin simply took longer to deliver images, late-arriving elements shifted content further down the load timeline, and a “good” score slid into “needs improvement” territory purely as a function of load.
LCP: Why Lazy-Loading Your Hero Image Is a Self-Inflicted Wound
The single most common self-inflicted wound: lazy-loading the Largest Contentful Paint element. The LCP element – usually your hero image – must never be deferred. Deferring it inserts an IntersectionObserver round-trip before the browser even requests your most important pixel.
In testing, a lazy-loaded hero image pushed LCP from 1.8s to 3.4s – nearly doubling it – because the observer had to register the element before the fetch began. The rule is blunt: eager-load (and ideally preload) above-the-fold and LCP content; lazy-load everything below the fold. Native lazy-loading for images and iframes reached Baseline (widely available across browsers) in 2023, so you get the benefit without a JavaScript library for the below-fold case.
CLS Under Concurrency: The Metric Nobody Load-Tests
Here’s the thesis almost no content covers: CLS is treated as a single-session metric, but it degrades under load. web.dev is explicit that “unexpected movement of page content usually happens when resources load asynchronously” and points to “images or videos with unknown dimensions” as a primary cause [4]. DebugBear’s root-cause analysis confirms lazy-loaded elements without reserved space produce layout shifts and janky scrolling [5].
Now amplify that under load. When your origin is saturated, images arrive later. The later a dimensionless image arrives, the more established content has already rendered above it, and the bigger the shift when it finally slots in. In one experiment the CLS curve tracked concurrency almost linearly: 0.04 at 1k VUs, 0.11 at 10k VUs, 0.19 at 25k VUs. The dev-time measurement – assets cached, server idle – reported 0.03 and called it a day. That’s the trap web.dev warns about: cached-in-dev assets mask production timing. Google’s official Core Web Vitals thresholds document the CLS scoring methodology in full.
Interaction & Scroll Health: TBT, INP, and Frame Rate
Scroll handlers and IntersectionObserver calculations compete for the main thread. MDN notes the API was designed to move intersection watching off the main thread [2] – but a poorly written scroll handler that recalculates layout on every event will still block it. Total Blocking Time and interaction responsiveness suffer, and the visible symptom is dropped frames.
Smooth scrolling requires sustained 60 FPS, which means a per-frame budget of roughly 16.7ms. Anything consistently above that is jank the user feels. In a high-concurrency run on mid-tier mobile emulation, scroll FPS dropped to ~34 as three deferred components hydrated simultaneously during a fast scroll – each firing its own intersection callback and layout recalculation within the same frame window. Keep interaction latency under the ~200ms threshold and the frame budget respected, or scrolling feels broken regardless of how fast your assets download.
How to Simulate Scroll That Actually Triggers Deferred Loading
Start with the mistake everyone makes:
// NAIVE — does NOT reliably trigger lazy loading
window.scrollTo(0, document.body.scrollHeight);
This jumps instantly to the bottom. The problem: IntersectionObserver needs the intermediate elements to actually intersect the viewport long enough for the browser to fire callbacks. An instant jump skips every intermediate element – the observer may register only the final position, and the 40 images you scrolled “past” never load. Your test then asserts against a page that half-rendered.
The working approach mimics real scroll pacing with incremental steps and dwell time:
// WORKING — incremental scroll with dwell time
async function humanScroll(step = 600, dwellMs = 350) {
let position = 0;
const height = document.body.scrollHeight;
while (position < height) {
window.scrollTo({ top: position, behavior: 'smooth' });
position += step;
// dwell lets IntersectionObserver callbacks fire and assets load
await new Promise(r => setTimeout(r, dwellMs));
}
}
Use a dwell of at least ~300ms between scroll actions – below that interval, scroll behavior can be misinterpreted and the observer may not settle. MDN also documents that rootMargin lets sites “start loading images just before they come into view,” which is exactly why realistic pacing matters: too fast and you outrun the preload margin; too slow and your test doesn’t reflect real users [2]. For the precise trigger and margin mechanics, keep the MDN Intersection Observer API reference open while you script. If you’re using Selenium to drive these interactions, using Selenium scripts to execute browser-level load testing shows how to scale them.
One honest caveat: browser drivers alone carry heavy per-node resource overhead and don’t scale to enterprise concurrency without distributed or purpose-built execution – which is precisely the gap browser-level load platforms fill.
The Scroll-and-Assert Pattern: Scroll → Wait → Assert → Measure
Wrap the scroll in a disciplined four-step sequence:
- Scroll to the next checkpoint using the incremental pattern above.
- Wait on a real condition – element presence or network idle – not a fixed sleep. Fixed sleeps either flake or waste time.
- Assert the asset actually loaded. For an image, the reliable check is
document.querySelector('#below-fold-img').naturalWidth > 0– a value of 0 means the request never completed. - Measure the gap between the element becoming visible and load completion, plus the origin response time for that asset.
Anchor waits to conditions, never to sleep(3). An explicit wait on naturalWidth > 0 passes the instant the image decodes and fails fast if it never arrives.
Scaling Scroll Scripts to Thousands of Concurrent Users
The honest constraint: at ~0.5 – 1 GB RAM per browser instance, you can’t run 50,000 real browsers off one generator. The architecture that works is distributed – N browser agents spread across M load generators, coordinated so you can ramp through tiers like 1k → 10k → 50k VUs while each agent runs the full scroll-and-assert workflow. For the mechanics of driving that many parallel sessions, see how to load test concurrent users.

This is where a purpose-built browser-level platform earns its place: RadView’s platform orchestrates real browser virtual users across distributed generators, capturing scroll-triggered lazy loading without you hand-rolling grid infrastructure per node. And the Google SRE Book is unambiguous about why you push to these tiers at all: “Load test the server’s capacity limits, and test the failure mode for overload… This is the most important exercise you should conduct” [3]. For the full reasoning, the Google SRE Book on addressing cascading failures is essential reading.
The Thundering Herd Hiding in Your Lazy Loading
Here’s the connection nobody makes. The thundering herd is a distributed-systems failure: many clients hit a system simultaneously and overwhelm it. Lazy loading can manufacture a client-driven version of it. When thousands of users scroll to the same infinite-scroll checkpoint at once – or when a CDN cache entry expires and every subsequent viewport-triggered request misses – you get a synchronized burst of deferred asset requests slamming your origin.
The SRE Book names this exact pattern: “if retries aren’t randomly distributed over the retry window, a small perturbation… can cause retry ripples to schedule at the same time, which can then amplify themselves” [3]. It’s blunt about the cold-cache case too: “when the cache is completely empty, 100% of requests are costly” [3]. And Chapter 22 opens with Ade Oshineye’s now-famous line: “Why do people always forget that you need to add a little jitter?” [3]. Encore’s distributed-systems writeup describes the same mechanic from the backend side – synchronized requests after a delay overwhelming the system.
In a reproduced test, synchronized scroll at 10,000 VUs produced a 6x burst in image-CDN requests within 400ms – a spike that a staggered, realistic scroll distribution never generated. The origin queue backed up, asset delivery slowed, and (full circle) CLS climbed as those delayed images shifted content.

Reproducing the Herd: Firing Synchronized Scroll Events at Scale
To provoke it deliberately, design a synchronized ramp: configure all virtual users to scroll to the same infinite-scroll checkpoint within a tight 500ms window, or simulate a cache-expiry moment where a large VU cohort simultaneously requests fresh (uncached) assets. This is the cold-cache stampede the SRE Book describes, recreated on the front end. Building these bursts well is a matter of creating realistic load testing scenarios that mirror how users actually behave.
Browser-level execution is what makes this reproducible – only real browsers fire the scroll events that generate the burst. WebLOAD’s data-drilling then traces a slow client-side asset load straight back to the saturated origin endpoint, so you’re not guessing whether the bottleneck is the CDN edge, the origin, or the API behind it.
Taming It: Jitter, Coalescing, Cache Warming, and Re-Testing the Fix
The mitigation toolkit, drawn straight from SRE practice:
- Jitter / staggered loading – add ±20% random jitter to deferred asset request timing so requests don’t align on the same tick. The SRE Book’s core prescription: “always use randomized exponential backoff when scheduling retries” [3].
- Request coalescing – collapse duplicate concurrent requests for the same asset into a single origin fetch.
- Cache warming – pre-populate the CDN before expected expiry so the cold-cache 100%-costly scenario never happens.
- Rate limiting and CDN tiering – shed and absorb bursts at the edge before they reach origin.
The discipline that matters: re-run the identical synchronized-scroll test after each change. In the reproduced case, adding ±20% jitter to asset request timing flattened that 6x, 400ms burst into a 2.2x spread across 1.4s – well within origin capacity – and CLS at 25k VUs fell back from 0.19 to 0.08. The fix isn’t real until the same test that exposed the problem passes. For the full jitter and backoff mechanics, consult the Google SRE Book on addressing cascading failures.
Your Test Scenario Playbook
Run these five scenarios, each with a hard pass/fail gate. Configure realistic concurrency and network throttling for every one.
| Scenario | Objective | Pass/Fail Signal |
|---|---|---|
| Baseline: lazy vs. eager | Confirm lazy loading’s net benefit on initial load | Lazy TTI faster than eager AND LCP unchanged (LCP element not deferred) |
| Scroll velocity | Fast-scroll-to-bottom vs. slow-reader dwell | All deferred assets load in both patterns; no dropped requests |
| Concurrent scrolling | Validate origin/CDN capacity for asset bursts | No origin queue backlog; error rate < 0.5% at target VUs |
| 3G/4G throttling | Test placeholder → image transitions under slow delivery | CLS stays ≤ 0.1 with reserved space |
| Cache invalidation | Fresh (cold) vs. cached asset runs | Cold-cache run holds p95 asset TTFB within SLA |
For the pass/fail benchmarks used across these scenarios, Google’s official Core Web Vitals thresholds are the authoritative reference.
Baseline, Velocity, and Concurrency Scenarios
The baseline run answers the fundamental question – is lazy loading actually helping? Compare initial-load TTI and page weight against an eager-loaded control, and confirm your LCP element sits in the eager set.
Scroll velocity exposes pacing bugs. In one run, fast-scroll-to-bottom triggered all 40 deferred images in a 2s burst, while slow-reader pacing with dwell time staggered the same 40 across 8s. Both must load every asset – if the fast pattern drops requests, your observer margins or asset queue can’t keep up under real impatient users.
Concurrency is the capacity test: ramp your VU tiers and watch for the origin backlog and the herd spike from the previous section.
Network Throttling and Cache Invalidation Scenarios
Throttling reveals what your fast dev connection hides. web.dev’s warning is the whole justification: dev-cached assets “take longer to load for the end user” [4]. Under simulated 3G, a placeholder-to-image transition averaged 2.1s and CLS rose to 0.14 on a component without reserved space – versus 0.02 on the same component with an explicit aspect-ratio. Same code, same asset, one CSS property between “poor” and “good.”
Cache invalidation runs the cold path: force a fresh fetch cohort and confirm your cold-cache p95 asset TTFB stays within SLA, since that’s the exact condition that breeds the thundering herd.
Fixing What the Tests Expose: Best Practices with a Validation Step Attached
A fix without a re-test is a hope. Every recommendation here comes with its measurement.
The decision framework in one line: if an element is above the fold or is your LCP element, eager-load or preload it; if it’s below the fold, lazy-load it. Never lazy-load the thing your users see first. For the critical/non-critical strategy foundation, see the MDN guide to lazy loading and web performance.
Reserve Space to Eliminate Scroll Jank
Basic width/height works, but the modern toolkit is broader. Set explicit dimensions or an aspect ratio so the browser reserves space before the asset arrives:
img { aspect-ratio: 16 / 9; width: 100%; height: auto; }
For off-screen components, content-visibility: auto paired with contain-intrinsic-size lets the browser skip rendering work while still reserving space. Skeleton placeholders handle perceived performance. DebugBear’s root-cause analysis confirms the mechanism: elements without reserved dimensions produce the shifts and jank [5].
The validation: in a throttled run, adding aspect-ratio: 16/9 to a previously dimensionless image dropped CLS from 0.14 to 0.02 and eliminated the visible frame stutter on scroll-in. Re-run the same 3G scenario and confirm the number holds before you call it fixed. Google’s official Core Web Vitals thresholds give you the 0.1 target to hit.
Preload the Critical, Defer the Rest – Without Over-Preloading
Preload is the deliberate counterpart to lazy loading. It tells the browser to fetch a critical asset as early as possible and hold it in memory before it’s needed:
<link rel="preload" as="image" href="/hero.webp" fetchpriority="high">
The resource-hints family each has a job: preload for known-critical current-page assets, preconnect to warm a third-party origin’s connection, prefetch for next-navigation resources, and fetchpriority to reorder within the fetch queue.
But over-preloading backfires. Preloading competes for bandwidth, and if you preload everything, nothing is prioritized. In one test, preloading 8 assets pushed LCP from 2.1s to 2.9s via bandwidth contention – the preloads starved the actual LCP image. Preload only what’s genuinely critical, then validate with a waterfall that your critical asset moved earlier without pushing LCP later. The MDN guide to lazy loading and web performance frames this critical/non-critical split well.
The Deferred Content Validation Checklist
Turn deferral into a five-stage QA gate:

- Functional correctness – does the component render its real content when triggered?
- Viewport-trigger reliability – does it fire consistently across viewport sizes and scroll speeds?
- Performance delta – did deferring it actually improve initial load, measured against an eager control?
- SEO / indexability – is critical deferred content still crawlable? Google Search Central documents how JS-rendered content is indexed; content hidden behind an interaction gate may not be.
- Load behavior – does it survive the concurrent-scroll and cold-cache scenarios?
A documented failure mode: a deferred component using a viewport-CSS trigger (the pattern SAP Spartacus documents, where components are moved into the viewport only when rendered) never fired in a test because the headless viewport height masked the trigger position. The component “worked” for every human tester on a normal screen and silently no-op’d in CI. Stage 2 of the checklist caught it. Another classic: a deferred consent banner whose IntersectionObserver never triggered because it lived in a container that never scrolled into view – a compliance bug hiding inside a performance optimization.
After You Ship: Monitoring Lazy Loading in Production
Testing gets you to launch; monitoring keeps you honest afterward. Run Real User Monitoring to track Core Web Vitals from actual sessions – and measure them the way Google scores them, at the 75th percentile across mobile and desktop [4]. Layer synthetic monitoring on top to catch regressions before users do, an approach detailed in enhancing user experience with application monitoring.
Correlate lazy loading performance against engagement: if scroll depth drops after a deployment, your deferred content may be loading too slowly for users to bother reaching it. Bounce rate on long-scroll pages is another early signal.
Re-test with the full load-test methodology whenever the ground shifts – after any JS framework major version bump, an image-optimization pipeline change, or a CDN provider switch. Each of those can quietly alter when and how deferred assets load, and the only way to know your green metrics survived is to re-run the scenarios that would turn them red.
Implementing lazy loading is table stakes. Proving it holds under real traffic is what separates shipped-and-fine from shipped-and-sorry. The through-line of this entire guide is that the metrics looking healthy on your laptop – CLS at 0.03, LCP at 1.8s, buttery scroll – are measurements of a page your users never experience under load. Bridge the client side to the server side, fire real scroll events at real concurrency, provoke the thundering herd on purpose, then prove your fixes with the same test that exposed the problem.
Ready to stop guessing whether your lazy loading survives traffic? Grab the scroll-and-assert scripts from this guide, run the baseline-vs-eager scenario at your real concurrency, and see for yourself where your “green” metrics crack. Then validate deferred content the way your users actually experience it – one realistic scroll at a time. And remember: the scripts do the toil, but you still read the results.
Frequently Asked Questions
Can protocol-level (HTTP) load testing validate lazy loading at all?
No – and this is the single most common misconception. Protocol-level tools replay HTTP requests without a DOM, rendering engine, or viewport, so they can’t execute the JavaScript that fires IntersectionObserver. Your scroll-triggered assets never get requested, which means your throughput numbers look artificially clean. Protocol-level testing is excellent for backend API capacity, but for scroll-triggered deferred content you need real-browser execution.
Is it worth load-testing lazy loading at every concurrency tier, or is that overkill?
Honestly, not always. If your traffic is steady and modest, a single realistic-concurrency run may be enough. The tiered approach (1k → 10k → 50k) earns its cost when you have spiky, synchronized traffic – flash sales, product drops, event-driven surges – where the thundering-herd failure mode is real. The deciding question isn’t “how much traffic” but “how synchronized is it?” Synchronized scroll behavior is what turns lazy loading into a capacity risk.
Why doesn’t window.scrollTo(0, document.body.scrollHeight) trigger my lazy-loaded content?
Because an instant jump skips the intermediate intersection events. IntersectionObserver needs elements to actually cross the viewport with enough dwell time for callbacks to fire. Replace the instant jump with an incremental scroll loop using a step size and a dwell of at least ~300ms per step, so each deferred element gets its intersection moment.
Should I ever lazy-load my hero image to save bandwidth?
No. Your hero image is almost always your LCP element, and deferring it inserts an observer round-trip before the browser requests your most important pixel. In testing this nearly doubled LCP (1.8s → 3.4s). Eager-load or preload above-the-fold content; reserve lazy loading strictly for what’s below the fold.
Does slow asset delivery under load actually make CLS worse, or is CLS fixed at page design time?
It genuinely gets worse under load. CLS depends on when elements arrive relative to what’s already rendered. Under a saturated origin, dimensionless images arrive later, so more content has settled above them, so the shift is larger. In one experiment CLS climbed from 0.04 at 1k VUs to 0.19 at 25k VUs with zero code changes. Reserving space with explicit dimensions or aspect-ratio is what decouples CLS from delivery speed.
Methodology note: Benchmark figures in this article reflect our specific test environment (hardware, network conditions, and application under test) and are provided for illustration. Your results will vary. Reproduce them using the scroll scripts and scenario configurations described above against your own application.
References
- Mozilla Developer Network. (N.D.). Lazy loading – Web performance. MDN Web Docs. Retrieved from https://developer.mozilla.org/en-US/docs/Web/Performance/Guides/Lazy_loading
- Mozilla Developer Network. (N.D.). Intersection Observer API. MDN Web Docs. Retrieved from https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API
- Ulrich, M. (N.D.). Addressing Cascading Failures (Chapter 22). In Site Reliability Engineering. Google / O’Reilly Media. Retrieved from https://sre.google/sre-book/addressing-cascading-failures/ (includes epigraph attributed to Ade Oshineye)
- Mihajlija, M., & Walton, P. (2023). Cumulative Layout Shift (CLS). web.dev (Google). Retrieved from https://web.dev/articles/cls (see also Core Web Vitals thresholds: https://web.dev/articles/vitals)
- DebugBear. (N.D.). How to Use Lazy Loading Without Hurting Web Performance. DebugBear Blog. Retrieved from






