跪拜 Guibai
← All articles
Frontend · JavaScript · Programmer

Don't Debounce Lazy Loading: Benchmark Loaded Only 4 of 120 Images

By kyriewen ·
Read original on juejin.cn ↗ Google Translate ↗ Alt translation

Fast-scrolling users on pages that rely on scroll + debounce lazy loading see unfilled images until they stop scrolling, a silent failure in long lists, anchor jumps, and End-key jumps. Native loading=lazy is more aggressive than most handwritten IO and costs one line, so the benchmark gives a data-backed reason to switch or at least widen rootMargin to 1000px.

Summary

A systematic comparison of nine lazy loading approaches on 120 images (67KB each, local server, cache disabled) produced a clear winner and one catastrophic loser. Scroll + debounce 250ms, a pattern still common in tutorials, loaded only 4 of 120 images when the page scrolled to the bottom in one continuous motion. The median image-ready delay was 615ms, and the mechanism is the problem: every scroll event resets the timer, so the load check never runs until scrolling stops. When it finally does, only the last few images are in view. The same setup with scroll + requestAnimationFrame had a 14ms delay, a 44x difference.

Native loading=lazy preloads at roughly 1250px and got images ready 851ms earlier on average. Handwritten IntersectionObserver code with the widely taught rootMargin of 200px only managed 119ms of early readiness; matching native required rootMargin of 1000px, which reached 685ms early. The benchmark also debunked two myths: fetchpriority=low had zero effect on lazy-loaded images, and bare scroll listeners didn't cause jank (median frame time stayed at 17ms with zero long tasks) but were slower than IO. Lazy loading's real payoff is bandwidth: it cuts initial requests from 120 to 1-7, not frame rate.

Takeaways
— Scroll + debounce 250ms loaded 4 of 120 images on a fast scroll; median image-ready delay was 615ms.
— Native loading=lazy preloads about 1250px and got 119 images ready 851ms earlier on average.
— IntersectionObserver with rootMargin: 200px only preloaded 119ms earlier; 1000px reached 685ms earlier, still not matching native's 851ms.
— fetchpriority=low made no difference to lazy-loaded images in this test.
— Bare scroll listeners didn't cause jank: median frame time stayed at 17ms with zero long tasks, but ready delay was 32ms vs 14ms for scroll + rAF.
— Lazy loading cuts initial requests from 120 to between 1 and 7, saving bandwidth rather than improving frame rate.
— For handwritten IO, set rootMargin to at least 200px; use 1000px to match native preload distance.
Conclusions

Debounce is fundamentally mismatched with scroll events because it resets on every trigger, so any continuous scroll postpones the load check indefinitely. This is a mechanism flaw, not a tuning issue.

The browser's native lazy loading is more aggressive than what most handwritten IO tutorials recommend (200px rootMargin). Following common advice makes users wait half a second longer, so the 'handwritten is better' assumption doesn't hold for typical configurations.

The perception that bare scroll listeners cause jank is overstated at this scale; the real cost is latency in ready time, not frame drops. Performance profiling should separate these two concerns.

Lazy loading's primary win is network savings, not rendering performance. Optimizing for frame rate with a scroll listener misses the point.

Concepts & terms
Debounce
A technique that delays a function call until a fixed time passes after the last trigger. Used to limit the frequency of calls on rapid events like typing. Attached to scroll events, it resets on every fire, so the load check never runs until scrolling stops.
IntersectionObserver
A browser API that asynchronously observes when an element intersects a viewport or another element. It avoids scroll event handlers and is the standard way to implement lazy loading with custom preloading offsets.
rootMargin
An IntersectionObserver option that expands or shrinks the root boundary before triggering the callback. A positive margin causes images to load before they enter the viewport; the article shows bigger margins lead to earlier readiness.
From the discussion

The discussion centers on whether handwritten scroll-based lazy loading can rival native loading=lazy. Debounce causes rapid scrolling to load only a few images, while throttle keeps checks regular. Native lazy's prefetch distance varies with network speed, so custom implementations must adjust rootMargin accordingly. A call to benchmark round-trip scrolling and a version upgrade issue also surface.

— Debounce delays checks indefinitely during fast scrolling, resulting in very few loads; throttle guarantees a check every 250ms and performs better.
— Chrome's native lazy loading prefetch distance is network-dependent: 1250px on 4G, 2500px on 3G or slower; tests from local servers reflect only the fast-network tier, so custom IntersectionObserver setups need larger rootMargin to match native on weak networks.
— Round-trip scrolling loading completion rate should be added as a benchmark metric to assess real-world behavior.
— Upgrading from version 3.2 to 4.0 changed some image-loading behavior, raising a question about whether others face the same regression.
Featured comments
亚雷

Can add the loading completion rate for back-and-forth scrolling.

AI向善

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.

See top comments, translated →
Source: juejin.cn ↗ Google Translate ↗ Backup ↗