ScreenshotNeo

BlogHow-to

How to Screenshot a Page That Loads Content with an IntersectionObserver

Trigger the page’s IntersectionObserver by scrolling, wait for a real loaded-state signal, then capture. Here’s a reliable Playwright workflow.

By the ScreenshotNeo team4 October 20269 min read

To screenshot content loaded by an IntersectionObserver, scroll the page or the relevant nested scroll container until the watched elements intersect the observer’s root, wait for a page-specific signal that the content is ready, and then capture. In Playwright, fullPage: true captures the document’s full scrollable extent, but it does not guarantee that scrolling has triggered every observer or that deferred resources have finished loading.

The reliable sequence is scroll, verify, capture. Use a known item, expected count, completed image state, or terminal marker as the readiness condition whenever possible. A delay or stable page height can help as a fallback, but neither proves that an application has finished loading.

1. Why a full-page screenshot can miss content

An IntersectionObserver asynchronously watches a target’s intersection with a root: by default, the viewport, or an ancestor element configured by the page. Sites commonly use it to lazy-load images and content or to fetch more entries for an infinite feed. A callback can run after observe() is called with the target’s initial intersection state; later callbacks can follow as the target crosses configured thresholds. Scrolling is what brings off-screen targets into the relevant intersection range on many pages. See MDN’s Intersection Observer API and observe() documentation.

Playwright defines a full-page screenshot as capturing the full scrollable page rather than only the viewport. That describes the output area; it does not promise to replay the page’s application-specific loading behavior. Likewise, the browser’s load event is not proof that all lazy resources are ready: MDN notes that lazy-loaded resources may not yet have loaded when it fires. See Playwright’s screenshot API and MDN’s lazy-loading guide.

2. A reliable Playwright workflow

  1. Identify the scroll root. Determine whether the page scrolls at the document level or inside a nested element such as a feed panel.
  2. Choose a readiness signal. Prefer a selector for the final expected item, a known item count, a loader becoming hidden, or a page-specific completion marker.
  3. Scroll through the relevant targets. Move in viewport-sized increments, or scroll a known locator into view. For nested scrolling, operate on that container.
  4. Wait for readiness. Use a locator wait or page.waitForFunction() for a condition the page actually exposes. Wait for images too if image pixels matter.
  5. Capture. Use page.screenshot({ fullPage: true }) for the document. Use a locator screenshot when only one element is needed.

Install Playwright with npm install playwright and install the browser binaries with npx playwright install chromium. Save the following as screenshot.mjs, replace the URL and selectors with values from the target page, then run node screenshot.mjs.

import { chromium } from 'playwright';

const url = 'https://example.com';
const expectedLastItem = '[data-item-id="last-expected-item"]';
const itemSelector = '.feed-item';

const browser = await chromium.launch();
try {
  const page = await browser.newPage({ viewport: { width: 1365, height: 900 } });
  await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30_000 });

  // This example assumes the document is the scroll root and the page has
  // a finite list with a known last item. Adapt the selectors to the site.
  const step = await page.evaluate(() => window.innerHeight);
  await page.locator(expectedLastItem).waitFor({ state: 'attached', timeout: 60_000 }).catch(async () => {
    for (let pass = 0; pass < 100; pass++) {
      await page.evaluate((distance) => window.scrollBy(0, distance), step);
      const found = await page.locator(expectedLastItem).count();
      if (found > 0) break;
      await page.waitForTimeout(200); // fallback settling time, not a readiness guarantee
    }
    await page.locator(expectedLastItem).waitFor({ state: 'attached', timeout: 10_000 });
  });

  // Ensure the expected content exists before capturing.
  await page.locator(expectedLastItem).waitFor({ state: 'visible', timeout: 10_000 });

  // Optional: wait for all currently rendered images to finish or fail.
  await page.waitForFunction((selector) => {
    return [...document.querySelectorAll(`${selector} img`)].every((img) => img.complete);
  }, itemSelector, { timeout: 30_000 });

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

The sample uses a known terminal item because it gives the loop a finite goal. The catch-and-scroll pattern is illustrative and should be adjusted to the page’s loading behavior; for pages where the item is already present, waiting for it directly is sufficient. An image’s complete property means loading has completed, including failure; if successful image decoding matters, also check img.naturalWidth > 0.

3. Scrolling a page without a known terminal item

If there is no known last item, scroll through the intended capture region and define an explicit stopping rule: a maximum number of entries, a maximum document height, a “no more results” marker, or a bounded number of scroll steps. Infinite scrolling may never reach a final height, so “capture everything” is not necessarily a finite request.

const maxSteps = 40;
const step = await page.evaluate(() => window.innerHeight);
let previousHeight = 0;
let stablePasses = 0;

for (let i = 0; i < maxSteps && stablePasses < 3; i++) {
  await page.evaluate((distance) => window.scrollBy(0, distance), step);
  await page.waitForTimeout(250); // heuristic settling delay

  const height = await page.evaluate(() => document.documentElement.scrollHeight);
  if (height === previousHeight) stablePasses++;
  else stablePasses = 0;
  previousHeight = height;
}

await page.screenshot({ path: 'page.png', fullPage: true });

This height-stability loop is only a heuristic. It can stop too early when content arrives late or is inserted without changing total height, and it can keep scrolling on feeds that continually fetch entries. Replace it with a page-specific condition when one is available. A fixed delay alone cannot establish readiness.

4. Nested scroll containers and element screenshots

If the observer uses a nested ancestor as its root, scrolling the window may not bring targets into that root’s intersection. Locate the actual scrollable container and scroll it. For example:

const feed = page.locator('.feed-scroll-area');
await feed.evaluate((el) => {
  el.scrollTop = 0;
});

for (let i = 0; i < 30; i++) {
  await feed.evaluate((el) => {
    el.scrollTop += el.clientHeight;
  });
  await page.waitForTimeout(200);
  if (await page.locator('[data-state="end-of-feed"]').count()) break;
}

await page.locator('.feed-scroll-area').screenshot({ path: 'feed.png' });

A locator screenshot scrolls its target into view before capture, which is useful for a single target. It does not by itself load every off-screen item in a long list. A screenshot of a scrollable container reflects that container’s visible region; it is not a substitute for a document-wide full-page capture.

5. Readiness checks that work

  • Known item or marker: wait for the final expected entry or an explicit end marker to appear.
  • Expected count: use page.waitForFunction() to wait until the number of rendered items reaches the intended count.
  • Loading indicator: wait for the page’s loader to become hidden or detached, if that accurately represents completion.
  • Image readiness: inspect the images in the capture region and wait for complete; check naturalWidth if successful decoding is required.
  • Height stability: use only as a fallback, with a step limit and a clear understanding that stable height can miss updates.

For example, wait for a known count of at least 20 cards:

await page.waitForFunction(() => {
  return document.querySelectorAll('.feed-item').length >= 20;
}, { timeout: 30_000 });

6. Troubleshooting

Symptom Likely cause Fix
Full-page image contains blank or placeholder sections The capture happened before scrolling triggered the observers, or before fetched content rendered. Scroll the relevant root, then wait for a known item, count, or completion marker before capturing.
Scrolling the window loads nothing The observed targets use a nested scroll root. Find the scrollable ancestor and change its scrollTop or use a locator within it.
Screenshot cuts off an infinite feed The feed has no finite end, or the loop stopped at a heuristic limit. Set a target count, item boundary, maximum region, or explicit stopping rule.
Images are missing although their elements exist DOM insertion occurred before image loading or decoding completed; a failed image may also have complete === true. Wait on image state and, when success matters, require naturalWidth > 0. Check the image request separately if it fails.
Wait for selector times out The selector is wrong, the item is never loaded, or scrolling is occurring in the wrong root. Inspect the page and selector, verify the root, and add a bounded scroll loop that checks progress.
Loop never terminates New content keeps increasing the height or the stopping condition is unreachable. Enforce a maximum number of steps and stop at a defined item count or region.
Content changes between runs Network timing, personalization, or dynamic page state changes what appears and when. Use a stable test account or fixture where possible, wait on page state rather than elapsed time, and record the chosen stopping condition.

7. Performance, reliability, and cost

Scrolling and waiting add time proportional to the content and page behavior. A viewport-sized step is a practical starting point; smaller steps can reduce the chance of skipping narrow intersection regions, while larger steps are faster but may jump over a target’s threshold range depending on the page’s observer settings and update behavior. Use a bounded number of steps so an infinite feed cannot consume unbounded time. Avoid arbitrary long sleeps on every step when a meaningful condition is available.

Reliability depends on choosing the correct scroll root and readiness condition. Network idle can be a useful signal for some pages, but pages with ongoing requests may never become idle, and network quiet does not prove a particular item or image is ready. Browser load completion has the same limitation for lazy resources. Capture only after verifying the specific content required for the output.

Browser automation cost depends on where and how often it runs: local compute, CI minutes, browser infrastructure, and the time spent waiting all matter. Keep screenshots bounded, reuse a browser process for batches where appropriate, and set navigation and readiness timeouts. The IntersectionObserver API and Playwright features cited here do not establish a universal runtime or cost figure, so benchmark your own pages and environment if those numbers affect a budget.

8. Or skip the browser setup

ScreenshotNeo is a website screenshot API and MCP server from Yorker Media. It can capture a URL as PNG, JPEG, WebP, or PDF. Its clean-shot flow accepts cookie or consent banners like a visitor and removes more than 60 known consent platforms, newsletter popups, and chat widgets; each step can be turned off. Bot checks and CAPTCHAs, blank pages, timeouts, failed loads, and cache hits cost nothing, and response headers report the page verdict and billing status.

For pages where the observer is triggered by scrolling, the API can save you from managing browser setup, but a one-call capture does not give you a custom Playwright loop for a page-specific terminal marker. If the page requires a precise application-specific scroll sequence, use the Playwright workflow above. ScreenshotNeo also provides an MCP server with take_screenshot, get_page_info, and capture_pdf tools for Claude, Cursor, and other MCP clients.

One-call example, using Stripe as the target URL:

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"}, timeout=90)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`Screenshot request failed: ${res.status}`);
await Bun.write('shot.webp', res);

See the ScreenshotNeo API documentation for request options. One thousand screenshots per month are free with no card; paid plans start at $5 for 3,000 screenshots. Plans range up to Business at $249 for 1,000,000, and yearly billing gives two months free; every feature is on every plan.

Cookie banners, popups, and chat widgets are removed before the shot; bot checks, blank pages, and failed loads are never billed; an MCP server lets AI agents take screenshots; 1,000 screenshots a month are free with no card, and paid plans start at $5 for 3,000. Create a free ScreenshotNeo account.

9. FAQ

Does an IntersectionObserver run only after scrolling?

No. Observing a target produces an initial callback on a later render cycle with its current intersection state. Scrolling is needed when targets start outside the relevant intersection and the page loads or updates them as they enter it.

Can I capture just one lazy-loaded element?

Yes. Scroll the locator into view, wait for the element’s content to become ready, then take a locator screenshot. That does not trigger every other off-screen target.

Does a stable document height mean the page is done?

No. Content can arrive later or change without altering total height. Treat height stability as a bounded fallback, not a completion guarantee.

Which event should I wait for before capture?

Use the page’s own signal for the content you need: a final item, expected count, loader state, or resource readiness. There is no universal browser event that proves all observer-driven content is ready.