Baobaobaolin.com
date
entry
055
topic
tooling
rev
—

Inside an off-screen iframe, not one loading="lazy" image loaded; under --dump-dom they all did

Load a page into an iframe at left: -10000px and check its images: all six have a naturalWidth of 0, and the server never got a request. Run the same page with --dump-dom and all six load.

When I check a mobile layout, I load the page into a same-origin iframe and read scrollWidth from the parent (screenshot widths can't be trusted, see entry 027). Measuring that way on screwkingtw, the site that became tigerfasteners.com, I also read every image's naturalWidth. A whole batch came back 0. It looked like the image files were broken.

They weren't. Those <img> tags had loading="lazy", and my iframe sat off screen. Rerunning the same setup today, the images that didn't load were never even requested; I assume the original batch failed the same way, with the browser deciding the images were too far from the viewport. This entry is about how lazy loading decides an image inside an iframe is "about to be visible". In order: which iframe placements load images and which don't; why distance is measured against the top-level viewport; how the same page under headless --dump-dom loads every image instead, and how far I got tracing that; and how to switch lazy loading off before measuring.

Measured: where the iframe sits decides how many images load

The original measuring script is gone, so every number below is from a rerun today. Google Chrome 156.0.8078.48, --headless=new, macOS, --window-size=1200,800 (wider than 500, clear of the floor in entry 027). Files served locally with python3 -m http.server; a CDP (Chrome DevTools Protocol) script in Node.js 22.19 opens the page and reads the result after 5 seconds.

The iframe is 375 wide. The page inside has one 200×100 lazy image at each of top 0, 600, 1500, 2500, 4000 and 8000px. Each image URL has its own query string, so the server log shows which ones were requested. The parent page waits 3 seconds after the iframe's load event and writes each image's naturalWidth into the page; that record is what the CDP script reads:

iframe placementimages loaded (top)
top left, 375×8000, 600, 1500
same, with visibility: hidden0, 600, 1500
top left, 9000 tall0, 600, 1500
top: 3000px (below the fold)none
left: -10000px (off screen)none

The server log agrees with naturalWidth: the images that didn't load were never requested. Two results went against my intuition. Stretching the iframe to 9000px, so the whole page is laid out inside it, didn't load any more images. And visibility: hidden didn't load any fewer. Under these conditions, what changed the result was where the iframe sat in the top-level viewport; going from 800 to 9000 tall, or adding visibility: hidden, didn't change how many images loaded.

Why: distance is measured against the top-level viewport

An iframe has its own viewport, but it isn't lazy loading's intersection root. The HTML Living Standard's lazy loading section gives each Document an IntersectionObserver whose options set only scrollMargin, with no root. The IntersectionObserver spec says that when root is null, the intersection root is "the top-level browsing context's document node", called the implicit root, and elements in nested browsing contexts (iframes) are compared against it too. The computation still passes through the iframe's own clipping and the scrollMargin, but what it finally compares against is the top-level viewport.

In other words, whether an image inside an iframe loads depends on how far it is from the visible area of the top-level window. Push the iframe to left: -10000px and every image in it is ten thousand pixels left of the top-level viewport, including the one at top 0. None of them loaded in the 5 seconds I watched.

As for how close counts as "about to be visible": web.dev's "Browser-level image lazy loading for the web" (last updated 2024-08-13) says Chrome's distance threshold is 1250px on fast (4G) connections and 2500px on 3G or slower, hard-coded and not changeable through the API. I put images directly in a top-level page: the one at top 1960 loaded, the one at 1980 didn't. innerHeight in the same run was 713 (read 5 seconds after opening the page; the same height that is 87px short in entry 027). 1960 − 713 = 1247 and 1980 − 713 = 1267, so this run's cutoff lies between 1247 and 1267px. That is consistent with 1250, but two points can't pin down an exact value. It's my inference from the numbers; I didn't check which connection type Chrome assigned to localhost. The coarser result in the table above (1500 loads, 2500 doesn't) is consistent with it too.

The same thing happens when the top-level page itself isn't visible. Open a tab with CDP's Target.createTarget and background: true, and document.visibilityState is hidden; not even the image at top 0 loads.

The other direction: --dump-dom loads everything

Every test above used --headless=new with a page opened over CDP. The same Chrome running the command-line --dump-dom flow gives the opposite result. With the six images in a top-level page, --dump-dom (with or without --virtual-time-budget), and --screenshot with --virtual-time-budget, request all six. A separate page recorded its state in the load event, run with --dump-dom (the screenshot run only has the server log): all six had a naturalWidth of 200, the loading attribute still read "lazy", visibilityState was visible, and innerHeight was 713. With the iframe at left: -10000px, run with --dump-dom --virtual-time-budget=8000, all six loaded as well.

I didn't find the cause. The HTML spec says loading isn't deferred when scripting is disabled, but my script did run; the state above is what it wrote into the page. I read components/headless/command_handler/headless_command.js at the same version: it opens the page with Target.createTarget, calls Page.navigate and waits for the load event, with nothing related to lazy loading that I could see. headless_command_handler.cc in the same directory doesn't contain the word lazy.

So the same page gives opposite answers depending on how you run it. A headless tab opened over CDP misses off-screen images; --dump-dom misses none. Neither tells you what a user sees after scrolling there.

Switch to eager before measuring

Since the measuring method changes the result, turn lazy loading off before you measure. In the parent, wait for the iframe's load event and set every image's loading to eager:

f.addEventListener("load", () => {
  for (const im of f.contentDocument.images) im.loading = "eager";
  // my test simply waited 3 seconds before reading naturalWidth
});

With the iframe at left: -10000px, all six images loaded after this. The HTML spec says that when the loading attribute is set to the Eager state, the image's lazy load resumption steps run and it continues loading, so changing the attribute is enough.

My original note said "set it to eager, then reset src". The rerun shows resetting src is redundant: the server log has two requests for each of the six images, one for the original URL triggered by the switch to eager, and one for the reset URL. I appended &eager when resetting, so the URLs differed and the cache couldn't help; I didn't test resetting to the same URL.

How I split it now: if the question is "do the image files exist", don't look at naturalWidth. Send an HTTP request for each src and check the status code; that has nothing to do with layout or lazy loading. If the question is layout, such as the height an image takes up or scrollWidth, set every image in the iframe to eager, wait for them to load, then measure.

If you remember one thing

When naturalWidth is 0, first check whether the image was ever requested. The original "broken images" left no server log. In today's rerun, the images that didn't load have no line in the log; the browser never asked for them. Where the iframe sits and which mode the browser runs in are part of the measurement. Leave those two conditions unwritten and you can't tell whether a number describes the page or the tool.

Revision history

  1. First published