Don't Debounce Lazy Loading: Benchmark Loaded Only 4 of 120 Images
Don't Use Debounce for Lazy Loading Anymore: Only 4 Out of 120 Images Loaded
The reason I started this was because I was switching lazy loading solutions for my gallery page and casually searched for tutorials. Every single one insisted its own approach was best and dismissed the others. I didn't see what there was to argue about, so I decided to run all 9 approaches I could find.
Let me give you the result so you don't waste five minutes: One approach loaded only 4 out of 120 images when scrolling to the bottom, and the remaining 116 never initiated a request at all. Even more infuriating, that approach appears quite often in tutorials.
How I Ran the Comparison
120 images, each 67KB, served locally, with caching disabled. For each image, I recorded the delay from when it entered the viewport to when it finished loading, and the server logged request timestamps.
Scrolling was uniform: 24 pixels per frame, straight to the bottom, simulating how you swipe through a long list with "I'm done, swiping away" speed. Each approach ran three rounds, and the results differed by less than 1 millisecond, so stable that I wondered if I'd forgotten to change the code.
All nine approaches: no lazy loading, native loading="lazy", native plus fetchpriority=low, IntersectionObserver with three preload offsets (0px / 200px / 1000px), bare scroll listener, scroll + rAF frame coalescing, and scroll + debounce at 250ms.
| Approach | Initial requests | Early readiness | Median image-ready delay |
|---|---|---|---|
| No lazy loading | 120 | 10 seconds early | 0 |
| Native lazy | 7 | 119 images 851ms early | 15ms |
| Native + low priority | 7 | 852ms early | 15ms |
| IO 0px | 2 | 0 | 15ms |
| IO 200px | 3 | 119ms early | — |
| IO 1000px | 6 | 685ms early | — |
| Bare scroll listener | 2 | 0 | 32ms |
| scroll + rAF | 2 | 0 | 14ms |
| scroll + debounce 250ms | 1 | — | 615ms |
Debounce Lazy Loading: A Blank Screen Champion
Look at the last row. The 615ms median delay isn't the worst part; the worst part is those 4 images.
Debounce works by resetting the timer on every trigger. As long as the user is scrolling, scroll events keep firing, and that 250ms timer gets indefinitely postponed, so the load check never executes. When the user finally stops at the bottom, the check runs — but only the last few images in the viewport are left.
Yes, debounce. The same debounce used for search boxes that wait 300ms after typing stops. Debounce itself isn't wrong; the mistake is attaching it to scroll events. It's like debouncing a delivery person: the more you urge them, the less they move, and they only leave with your food after you've starved.
The control group, scroll + rAF frame coalescing, had a 14ms ready delay. That's a 44x difference, on the same machine, with the same images, the only difference being that 250ms timer.
If your codebase still has scroll + debounce lazy loading, don't fix it, just delete it. The only advantage of this approach is saving bandwidth — because the images never load, so of course it saves bandwidth.
Handwritten Code Loses to a One-Line Browser Attribute
The handwritten approaches without debounce all performed normally; the problem lies elsewhere.
For native loading="lazy", the preload distance is officially about 1250px according to Chrome. Measured data confirms this: with native, images are ready an average of 851ms earlier.
Meanwhile, among handwritten IntersectionObserver tutorials, nine out of ten teach rootMargin: 200px. Measured, that configuration only gets 119ms early. To match native, you need to push preload to 1000px, which measured 685ms early.
In other words: you write twenty lines of JS to maintain an IntersectionObserver, and users still wait half a second longer than with the browser's built-in one-line attribute. Is the handwritten camp wronged? Yes. But the data doesn't lie.
Two Myths Debunked
"fetchpriority=low optimizes lazy loading" — I ran a round with it, and the data was identical to without it. This attribute is for grabbing bandwidth for above-the-fold images, not for lazy-loaded lists. Forget about it.
"Bare scroll listeners cause jank" — At this scale of 120 images, the median frame time across all nine approaches was 17ms, with zero long tasks. A bare scroll listener doing a full getBoundingClientRect scan doesn't cause jank; it's just twice as slow in ready delay compared to IO. If you want to criticize it, the issue is slowness, not jank.
So what does lazy loading actually optimize? Look at the initial requests column: without lazy loading, the page grabs all 120 images at once on load; with lazy loading, it only takes 1 to 7. It saves bandwidth, and saves your server's bandwidth bill, not frame rate.
Someone Might Say
"Can headless Chrome data really count?" The frame rate result might not reproduce on real devices; I'll concede that. But the debounce failure is a mechanism problem, and preload distance is browser behavior. Those two have nothing to do with the machine it runs on.
"Did you deliberately include debounce to pad the numbers?" I'd love to pad, but unfortunately it failed the hardest. And this scenario isn't rare: fast-scrolling long lists, anchor jumps, pressing the End key — all scroll straight to the bottom. Debounce lazy loading works okay for slow browsing, but dies when scrolling fast, and it hides itself when the test case is slightly slow — that's what makes it insidious.
The Correct Approach, Copy It Directly
<!-- Above-the-fold images: eager + high priority, don't lazy-load LCP -->
<img src="hero.jpg" loading="eager" fetchpriority="high" width="800" height="600">
<!-- Everything else: one native line, preload 1250px, more aggressive than most handwritten -->
<img src="a.jpg" loading="lazy" width="300" height="225">
If you really need to write your own IO (for skeleton screens, fade-ins, etc.), set rootMargin to at least 200px, or 1000px to match native:
const io = new IntersectionObserver((entries) => {
for (const e of entries) {
if (e.isIntersecting) {
e.target.src = e.target.dataset.src;
io.unobserve(e.target);
}
}
}, { rootMargin: '1000px 0px' });
document.querySelectorAll('img[data-src]').forEach(img => io.observe(img));
Finally, a quick survey: do you still have scroll + debounce lazy loading in your project? Raise a hand in the comments, let me see how many of us crawled out of that pit.
Top 4 from juejin.cn, machine-translated. The original thread is authoritative.
Can add the loading completion rate for back-and-forth scrolling.
How to analyze? I don't know how to use F12. Is there a tutorial you can send?
For the version that only loads a subset of images, I locked it at 3.2; after upgrading to 4.0, some behavior changed. Have any of you encountered this?
Switching debounce to throttle makes it work: during scrolling, at least one check every 250ms, so it won't get pushed back indefinitely. Also, Chrome's prefetch distance for native lazy loading depends on network speed: 4G 1250px, 3G or below 2500px. Measurements from a local server reflect the fast-network tier; to match native under weak networks with a custom IO, rootMargin needs to be increased further.