IntersectionObserver: Lazy Load Trigger in Captures

IntersectionObserver tells a script when an element enters the viewport. Why full-page captures do not fire it, a measured fix, and code for each tool.

By Simon Chiu · Updated

IntersectionObserver is a browser API that calls a script function when an observed element starts or stops overlapping the viewport (or another root element) by a chosen amount, without the script having to listen to scroll events.

Lazy loaders, infinite scroll feeds, "fade in on scroll" animations and ad viewability counters all depend on it. This entry is part of the screenshot and rendering glossary. For the image side of the problem, see lazy loading.

A plain explanation

Before this API, a script that wanted to know "is this element on screen?" had to listen to every scroll event and measure positions, which was slow. Now the script says "tell me when this element is at least 25% inside the viewport" and the browser calls back when that happens. The browser does the geometry once per frame.

The result for a capture tool: anything that waits for an observer callback waits for the viewport to reach it. If the viewport never moves, the callback never runs.

What a full-page capture does to observers

We built a page with twelve 600 px sections. Each section is observed with threshold: 0.25 and turns green when its callback runs. The viewport was 800 x 800. We counted callbacks after each action.

The top four sections of two full-page captures. Left, a normal full-page capture: sections 1 and 2 are green, sections 3 and 4 are grey because their observers never fired. Right, after the viewport was made as tall as the page: all sections are green.
Left: Puppeteer fullPage: true. Right: the same page after setViewport to the full page height (7,248 px). The top 2,416 px of each capture, scaled down. 2026-10-15.
Action Sections seen (of 12)
Puppeteer: load 2
Puppeteer: full-page screenshot 2
Puppeteer: viewport set to page height (7,248 px) 12
Puppeteer: scroll through in 600 px steps 12
Playwright: load 2
Playwright: full-page screenshot 2

A full-page capture paints the whole document, but the layout viewport and scroll position stay the same, so for the page nothing has changed. Two fixes worked: scroll through the page, or make the viewport as tall as the page. The second is faster, because every element intersects at once.

How it works technically

root (viewport) rootMargin: 0 0 200px 0 element, 25% inside threshold 0.25 reached: callback runs element further down: no callback
The root, its margin and the threshold decide when a callback runs. The right-hand boxes stand for positions on the page, not for screen coordinates.

An observer has three settings. The root is the viewport, unless you pass a scrolling element. The rootMargin grows or shrinks the root's box, for example 0px 0px 200px 0px to start loading 200 px early. The threshold is one value or a list of values between 0 and 1: the share of the element that must be inside the box. On each rendering frame, the browser computes the overlap for every observed element and queues a callback when a threshold is crossed. Callbacks are asynchronous, so allow a frame or two before you check the result.

Because the root is the viewport, anything that changes the viewport size counts. That is why a tall viewport works: the root's box now covers the whole document.

How to trigger it in Puppeteer, Playwright and RenderScreenshot

Puppeteer. Make the viewport as tall as the page, wait for the callbacks and the requests they start, then capture.

import puppeteer from 'puppeteer';

const url = process.argv[2] ?? 'https://example.com';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

const browser = await puppeteer.launch();
try {
  const page = await browser.newPage();
  await page.setViewport({ width: 1280, height: 800 });
  await page.goto(url, { waitUntil: 'networkidle0' });

  // Make the viewport as tall as the page: every observed element now
  // intersects the root, so every observer callback fires.
  const height = await page.evaluate(() => document.documentElement.scrollHeight);
  await page.setViewport({ width: 1280, height: Math.min(height, 16_000) });
  await sleep(300); // observer callbacks run on the next frames
  await page.waitForNetworkIdle({ idleTime: 500 }); // images the callbacks started

  await page.screenshot({ path: 'observed.png', fullPage: true });
  console.log('page height', height);
} finally {
  await browser.close();
}

Playwright.

import { chromium } from 'playwright';

const url = process.argv[2] ?? 'https://example.com';
const browser = await chromium.launch();
try {
  const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
  await page.goto(url, { waitUntil: 'networkidle' });
  const height = await page.evaluate(() => document.documentElement.scrollHeight);
  await page.setViewportSize({ width: 1280, height: Math.min(height, 16_000) });
  await page.waitForTimeout(300);
  await page.waitForLoadState('networkidle');
  await page.screenshot({ path: 'observed-pw.png', fullPage: true });
  console.log('page height', height);
} finally {
  await browser.close();
}

RenderScreenshot. Use scroll simulation with a step smaller than the viewport, so each element spends at least one pause inside it. For reveal animations that only add a class, you can also add the class yourself with page.scripts. See capture options and page modifications.

curl -X POST https://api.renderscreenshot.com/v1/screenshot \
  -H "Authorization: Bearer $RS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/landing",
    "capture": {
      "mode": "full_page",
      "scroll": { "simulate": true, "step": 400, "delay": 300 }
    },
    "page": {
      "scripts": ["document.querySelectorAll(\".reveal\").forEach((el) => el.classList.add(\"is-visible\"))"]
    }
  }' \
  --output landing.png

Tested on 2026-10-15 with Node 22.18.0, Puppeteer 25.14.0 (Chrome 155) and Playwright 1.64.1 (Chromium 156), against https://example.com and the local 12-section page (12 of 12 sections green in both outputs). The RenderScreenshot request follows the API docs and was not run for this page; .reveal and is-visible stand for your own class names.

Common mistakes

  • Trusting fullPage to trigger anything. It does not scroll and does not resize the viewport.
  • A tall viewport on a page with 100vh sections. Hero sections sized with vh grow with the viewport. Check the layout, or scroll instead.
  • Checking too early. Callbacks run on a later frame. Wait a frame, then wait for the requests they started.
  • Observers with a scrolling root. If the root is a scrolling <div>, a tall window does not help. Scroll that element instead.

Our lazy loading study tests five capture strategies against four lazy-load patterns, including observer-based loaders. To capture long pages with scroll simulation built in, get a free API key.

Frequently asked questions

Does a full-page screenshot trigger IntersectionObserver?
Not in our test. Puppeteer and Playwright full-page captures left 10 of 12 observed sections untouched, because the viewport and scroll position did not change.
What do rootMargin and threshold do?
rootMargin grows or shrinks the area that counts as visible, so a lazy loader can start early. threshold is the share of the element that must be inside that area before the callback runs.

Related in this topic

Try RenderScreenshot free

150 free credits every month, no credit card required.