ScreenshotNeo

BlogHow-to

Capture Lazy-Loaded Content in a Screenshot Without Changing the Page’s Scroll Position

Load scroll-triggered content, restore the original scroll coordinates, and capture the page. Includes Playwright and Puppeteer examples, troubleshooting, and an API option.

By the ScreenshotNeo team4 October 20268 min read

To capture lazy-loaded content without changing the page’s scroll position at capture time, save both scroll coordinates, scroll through the content in viewport-sized steps so lazy loaders can activate, wait until the required content is ready, restore the coordinates, and take the screenshot. Use a full-page screenshot if you need the whole document; omit that option for a viewport-only image.

This returns the page to its starting coordinates before capture. It does not guarantee that the page never visibly moves while the content is loading, or that every site’s lazy-loading logic will respond to this sequence. For a page that must not visibly move, consider a separate page or context and verify that its lazy loading still activates.

Why full-page screenshots can miss lazy-loaded content

Full-page capture controls how much of the document appears in the output. It does not necessarily trigger the page’s scroll-dependent loading behavior. Native lazy loading can defer offscreen images, iframes, video, and audio until they approach the viewport, and JavaScript can use Intersection Observer to load content when a target intersects the viewport or a scroll container. A browser’s load event can fire while lazy resources are still unloaded. See MDN’s guides to lazy loading and the Intersection Observer API.

Playwright’s fullPage option captures the full scrollable page, and Puppeteer offers full-page screenshots too. Those options describe capture extent; check that the content and images you need have actually loaded before relying on the result. See the Playwright screenshot guide and Puppeteer screenshot guide.

Playwright: scroll, wait, restore, and capture

This Node.js example uses Playwright. It saves the original horizontal and vertical offsets, walks through the document to activate scroll-triggered loading, waits for images to finish, restores the offsets, and captures the full page. Replace the example URL and target selector with your own.

import { chromium } from 'playwright';

const url = 'https://example.com';
const readySelector = 'main';
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 1365, height: 900 } });

try {
  await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 60000 });
  await page.locator(readySelector).waitFor({ state: 'visible', timeout: 30000 });

  const original = await page.evaluate(() => ({ x: scrollX, y: scrollY }));

  try {
    let previousHeight = 0;
    let stablePasses = 0;

    // Recheck document height because loading may append more content.
    while (stablePasses < 2) {
      const height = await page.evaluate(() => document.documentElement.scrollHeight);
      const viewportHeight = await page.evaluate(() => innerHeight);

      for (let y = 0; y < height; y += viewportHeight) {
        await page.evaluate(y => scrollTo(0, y), y);
        await page.waitForTimeout(150);
      }

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

    // Wait for currently present images to finish loading or fail.
    await page.waitForFunction(() =>
      [...document.images].every(image => image.complete)
    , { timeout: 30000 }).catch(() => {});

    await page.evaluate(({ x, y }) => scrollTo(x, y), original);
    await page.waitForTimeout(100);
    await page.screenshot({ path: 'page.png', fullPage: true });
  } finally {
    // Restore even if a wait or screenshot step throws.
    await page.evaluate(({ x, y }) => scrollTo(x, y), original).catch(() => {});
  }
} finally {
  await browser.close();
}

Install the dependency with npm install playwright and install its browser with npx playwright install chromium. Save the example as an .mjs file and run it with Node.js. The short per-step delay is only a settling hint; for a page you control, prefer a selector or application signal that indicates the specific content is ready.

The image check waits for images currently in the DOM to complete, including failed images. It does not prove that each image succeeded or that a site will not insert more images afterward. Add checks for the particular image URLs or rendered content your capture requires.

Puppeteer: the same workflow

Puppeteer follows the same approach. Its element screenshot behavior can scroll a hidden element into view, so restore the saved coordinates after an element capture if the page must end at its starting position.

import puppeteer from 'puppeteer';

const browser = await puppeteer.launch({ headless: true });
const page = await browser.newPage();
await page.setViewport({ width: 1365, height: 900 });

try {
  await page.goto('https://example.com', {
    waitUntil: 'domcontentloaded',
    timeout: 60000
  });
  await page.waitForSelector('main', { visible: true, timeout: 30000 });

  const original = await page.evaluate(() => ({ x: scrollX, y: scrollY }));

  try {
    let previousHeight = 0;
    let stablePasses = 0;

    while (stablePasses < 2) {
      const { height, viewportHeight } = await page.evaluate(() => ({
        height: document.documentElement.scrollHeight,
        viewportHeight: innerHeight
      }));

      for (let y = 0; y < height; y += viewportHeight) {
        await page.evaluate(y => scrollTo(0, y), y);
        await new Promise(resolve => setTimeout(resolve, 150));
      }

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

    await page.waitForFunction(() =>
      [...document.images].every(image => image.complete)
    , { timeout: 30000 }).catch(() => {});

    await page.evaluate(({ x, y }) => scrollTo(x, y), original);
    await new Promise(resolve => setTimeout(resolve, 100));
    await page.screenshot({ path: 'page.png', fullPage: true });
  } finally {
    await page.evaluate(({ x, y }) => scrollTo(x, y), original).catch(() => {});
  }
} finally {
  await browser.close();
}

Install with npm install puppeteer, save as .mjs, and run with Node.js. If you capture a single element instead of the full page, Puppeteer may scroll it into view; the cleanup step restores the original coordinates.

Adapt the workflow to the page

Wait for the content you actually need

A fixed delay can be useful for a quick script, but it is not a reliable readiness signal. Prefer waiting for a known selector, an image’s naturalWidth to become nonzero, or an application-specific event. Check that the target exists and is visible after the scroll sweep. A completed image request can still be a failed image, so inspect the result when image success matters.

Account for expanding pages

Some pages append content as you approach the bottom. Re-read scrollHeight after each sweep and continue until the height and target content stop changing, or until a site-specific completion condition is met. The sample uses two stable passes as a practical stopping heuristic, not a universal guarantee.

Handle infinite feeds and virtualized lists

An infinite feed may have no final height. A virtualized list may remove offscreen items from the DOM as new ones appear. A single sweep cannot guarantee that every item will be present in one screenshot. Define what you need to capture: for example, a specific item, a known number of feed entries, or a site-provided end marker. If the page only renders visible items, capture the relevant segments separately or use an application export that includes the full data.

Choose the right screenshot extent

  • Full document: use Playwright’s fullPage: true or Puppeteer’s fullPage: true after the content is ready.
  • Current viewport: omit fullPage and capture after restoring the original position.
  • Specific element: wait for the element and account for the automation library potentially scrolling it into view. Restore coordinates afterward if required.
  • Browser protocol: Chrome DevTools Protocol exposes Page.captureScreenshot with captureBeyondViewport. This controls capture beyond the viewport; it does not by itself establish that a site’s lazy content has loaded. See the CDP Page documentation.

Common problems and fixes

Problem Likely cause Fix
Images are missing in the full-page output The capture ran before scrolling activated lazy loading, or before image requests finished. Scroll through the relevant sections, then wait for the target images and verify they loaded before capture.
The page is back at the old coordinates but shows different content New content or images changed the layout above the saved coordinate. Wait for layout changes to settle. If possible, restore the target element to view with an element-based strategy and then save or restore the final position as needed.
The script stops before reaching the bottom Content appended during the sweep and increased the document height. Re-read the height and repeat the sweep until a page-specific end condition or a stable height is reached.
Some feed entries never appear in one image The site uses infinite loading or virtualizes offscreen items. Use a known completion condition, capture segments, or obtain the content through an application export.
waitForFunction times out An image never completes, the page is continually changing, or the condition is too broad. Wait on the specific required images or selector, handle failed images explicitly, and set a bounded timeout.
Capture is blank or navigation times out The page did not render successfully, navigation is blocked, or the chosen navigation event never occurs. Check the URL and browser logs, use an appropriate navigation readiness condition, and verify the page is present before scrolling.
Screenshot shifts the page unexpectedly An element capture may scroll the element into view, or page scripts may alter scrolling. Save both coordinates and restore them after the capture operation; inspect site scroll handlers if movement continues.

Performance, reliability, and cost

Scrolling the whole document adds work proportional to the number of viewport steps, plus the time spent waiting for content. Keep the sweep limited to the sections needed when a full-page result is unnecessary. Waiting for a known selector or image is usually more predictable than adding a long delay to every step.

Dynamic pages can change height while loading, and restoring a coordinate does not restore the exact same visual content if layout above it shifted. A page that depends on user interaction, authentication, or a particular scroll container may need a page-specific workflow. The MDN and browser-library documentation describe the underlying mechanisms and screenshot options, but do not guarantee this sequence for every website or browser.

Self-hosted browser automation has no per-screenshot API charge, but you provide the browser runtime and execution environment. For hosted capture, account for the provider’s billing rules, output format, and behavior on failed pages; check those details before moving a bulk workflow.

Or skip the browser setup

ScreenshotNeo is a website screenshot API and MCP server. Its one-call API can return a screenshot or PDF, and its full-page capture loads lazy images. See the ScreenshotNeo API documentation for request options.

curl -G "https://api.screenshotneo.com/v1/shot" \
  -d access_key=YOUR_API_KEY \
  --data-urlencode url=https://example.com \
  -o shot.webp
import requests

r = requests.get(
    "https://api.screenshotneo.com/v1/shot",
    params={"access_key": "YOUR_API_KEY", "url": "https://example.com"},
    timeout=90,
)
r.raise_for_status()
with open("shot.webp", "wb") as f:
    f.write(r.content)
const q = new URLSearchParams({
  access_key: 'YOUR_API_KEY',
  url: 'https://example.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);

Cookie and consent banners are accepted before capture, and 60+ known consent platforms, newsletter popups, and chat widgets can be removed; each step can be turned off. Bot checks and CAPTCHAs, blank pages, timeouts, failed loads, and cache hits are not billed, and response headers report the page verdict and billing status. An MCP server gives AI agents tools for screenshots, page information, and PDF capture. The free plan includes 1,000 screenshots a month with no card; paid plans start at $5 for 3,000.

Sign up free for 1,000 screenshots a month with no card.

FAQ

Does a full-page screenshot trigger every lazy loader?

Do not assume so. Full-page options describe the capture area; a page may require actual scroll or intersection events before it loads offscreen content.

Will restoring scroll coordinates put the same element under the cursor?

Not necessarily. If the page inserts content or changes layout above the saved coordinates, the same coordinates can show a different part of the page.

Can I guarantee the page never visibly scrolls?

This workflow restores the saved position before capture, but it scrolls during loading. A separate page or context may avoid visible movement, but confirm the target site’s lazy loading works there.