What’s actually slowing this PC down?

Pick the symptom - the matching free tool is one click away.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Some links on this page are affiliate links: if you buy through them we may earn a commission, at no extra cost to you.

Use a real browser, scroll the page (or its scrolling container) to trigger lazy loading, wait for a page-specific readiness signal, and only then take the screenshot. In Playwright or Puppeteer, fullPage: true controls the capture extent; it does not guarantee that JavaScript driven by scrolling, IntersectionObserver, or infinite loading has run.

The reliable sequence is navigation, consent or overlay handling, incremental scrolling with a safety limit, a meaningful wait condition, full-page capture, and a visual or programmatic completeness check.

Why a full-page option can still produce blank sections

Playwright describes a full-page image as one that fits the entire scrollable document “as if you had a very tall screen” (Playwright screenshot documentation). That describes how far the screenshot extends, not whether the site has rendered every image or data block.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Lazy-loading implementations commonly wait for an element to approach the visual viewport. Others listen for scroll events, fetch the next page of an infinite feed, or render only rows near the viewport. An off-viewport full-page capture may not move the visual viewport, so those handlers might never run. Playwright issue #40941 records this as a known concern involving lazy images, IntersectionObserver, scroll-triggered animation, and virtualized lists; it is an issue report, not a promise that every page behaves this way (issue #40941). The browser API observes a target’s intersection with a viewport or ancestor, which is why bringing content into view matters (MDN Intersection Observer API).

A robust Playwright workflow in Node.js

Install Playwright and its browser binaries in the project that will run the capture:

npm install playwright
npx playwright install chromium

This complete example scrolls one viewport at a time, stops when the document height stops increasing, waits briefly for asynchronous work, then captures the result. The iteration limit prevents an endless feed from running forever.

const { chromium } = require('playwright');

(async () => {
  const browser = await chromium.launch();
  const page = await browser.newPage({
    viewport: { width: 1280, height: 900 },
    deviceScaleFactor: 1
  });

  try {
    await page.goto('https://example.com', {
      waitUntil: 'domcontentloaded',
      timeout: 60_000
    });

    // If the site has a consent dialog, dismiss it here with a site-specific selector.
    // await page.getByRole('button', { name: 'Accept all' }).click();

    let previousHeight = 0;
    const maxScrolls = 30;

    for (let i = 0; i < maxScrolls; i += 1) {
      const heightBefore = await page.evaluate(
        () => document.documentElement.scrollHeight
      );

      await page.evaluate(() => {
        window.scrollBy(0, window.innerHeight);
      });

      // Give image decoding and application fetches a chance to finish.
      await page.waitForTimeout(400);

      const heightAfter = await page.evaluate(
        () => document.documentElement.scrollHeight
      );
      if (heightAfter === previousHeight || heightAfter === heightBefore) {
        // One extra pause catches a request that completed just after the check.
        await page.waitForTimeout(400);
        const finalHeight = await page.evaluate(
          () => document.documentElement.scrollHeight
        );
        if (finalHeight === heightAfter) break;
      }
      previousHeight = heightAfter;
    }

    // Prefer a condition that represents the page you expect, when available.
    // await page.waitForSelector('[data-testid="all-results"]', { timeout: 15_000 });
    // await page.waitForFunction(
    //   () => document.querySelectorAll('.result-card').length >= 100,
    //   { timeout: 15_000 }
    // );

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

The goto wait state only says that the initial DOM event occurred. Replace the commented waits with a selector, item count, or application flag that means “all content needed for this image is ready.” A fixed delay by itself cannot prove completeness.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Use a target condition instead of document height when possible

Document height is a useful generic signal, but it is not sufficient for every application. If the page promises a “Showing 1–200 of 200” label, wait for that label. If a gallery has a known final image, wait for that image’s complete property and a nonzero natural width:

await page.waitForFunction(() => {
  const image = document.querySelector('#last-gallery-image');
  return image && image.complete && image.naturalWidth > 0;
}, { timeout: 30_000 });

For a known list size, wait on the count rather than guessing how long the network will take:

await page.waitForFunction(
  expected => document.querySelectorAll('[data-row]').length >= expected,
  20_000,
  120
);

Scroll nested containers, not just the window

Many dashboards keep the document short while an inner element owns the scrollbar. Identify that element and advance its scrollTop:

const selector = '.results-pane';
for (let i = 0; i < 50; i += 1) {
  const before = await page.locator(selector).evaluate(el => el.scrollHeight);
  await page.locator(selector).evaluate(el => {
    el.scrollTop += el.clientHeight;
  });
  await page.waitForTimeout(300);
  const after = await page.locator(selector).evaluate(el => el.scrollHeight);
  if (after === before) break;
}
await page.locator(selector).screenshot({ path: 'results-pane.png' });

If you need the whole document as well as the nested panel, scroll the panel first, then use the page screenshot. For a panel-only deliverable, an element screenshot avoids unrelated navigation and footer content.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Handle infinite feeds and virtualized lists deliberately

An infinite feed has no natural “last” height. Set a maximum number of scrolls, maximum elapsed time, or explicit item target. Record the item count after each iteration and stop when it reaches the business requirement. A virtualized list may remove rows that have left the viewport; a single full-page image cannot reconstruct rows that the page no longer keeps in the DOM. In that case, capture successive viewport images or use the application’s export endpoint instead of assuming fullPage can enumerate an unbounded feed.

Puppeteer equivalent

Puppeteer’s official guide documents page screenshots and element screenshots (Puppeteer Screenshots). The same scroll-and-wait principle applies:

const puppeteer = require('puppeteer');

(async () => {
  const browser = await puppeteer.launch({ headless: true });
  const page = await browser.newPage();
  await page.setViewport({ width: 1280, height: 900, deviceScaleFactor: 1 });

  try {
    await page.goto('https://example.com', {
      waitUntil: 'domcontentloaded',
      timeout: 60_000
    });

    let lastHeight = 0;
    for (let i = 0; i < 30; i += 1) {
      const height = await page.evaluate(() => document.documentElement.scrollHeight);
      await page.evaluate(() => window.scrollBy(0, window.innerHeight));
      await new Promise(resolve => setTimeout(resolve, 400));
      const nextHeight = await page.evaluate(() => document.documentElement.scrollHeight);
      if (nextHeight === lastHeight || nextHeight === height) break;
      lastHeight = nextHeight;
    }

    await page.waitForSelector('.content-ready', { timeout: 15_000 });
    await page.screenshot({ path: 'page.png', fullPage: true });
  } finally {
    await browser.close();
  }
})();

Choose the library your project already uses, then check whether the target reacts to real scrolling, uses nested containers, or virtualizes rows. The cited documentation does not establish a universal winner for speed, fidelity, or reliability.

Make the capture deterministic

Remove blockers before scrolling

Consent dialogs, newsletter modals, sticky chat buttons, and bot challenges can cover content or stop scripts. Dismiss known dialogs before the first scroll, and fail clearly when a challenge appears instead of saving an apparently complete but unusable image. Keep authentication cookies and request headers in the browser context when the page is private; never hard-code credentials in source control.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Wait for images, fonts, and application data

After the final scroll, wait for a page-specific marker and inspect image elements. A useful diagnostic is to count images that are still incomplete:

const pendingImages = await page.evaluate(() => [...document.images]
  .filter(img => !img.complete || img.naturalWidth === 0).length);
console.log({ pendingImages });

When image decoding is the issue, wait for the browser to settle before capture:

await page.evaluate(async () => {
  if (document.fonts) await document.fonts.ready;
  await Promise.all([...document.images].map(img => {
    if (img.complete) return Promise.resolve();
    return new Promise(resolve => {
      img.addEventListener('load', resolve, { once: true });
      img.addEventListener('error', resolve, { once: true });
    });
  }));
});

Do not wait forever for a broken third-party image; the error handler above lets the capture continue while your verification step reports what failed.

Check what you saved

  • Confirm the expected heading, final item, and footer are present with locators before taking the image.
  • Compare the screenshot dimensions with the measured document or element dimensions.
  • Open the output and look for blank image boxes, repeated rows, clipped sticky elements, and overlays.
  • Keep the browser open only as long as necessary, and close it in a finally block so crashes do not leak processes.

Performance, reliability, and cost considerations

Scrolling more steps increases network requests and browser time. Use the smallest viewport that still represents the required design, cap iterations, and avoid loading unrelated resources only when doing so does not change the page you are documenting. Reuse a browser process for a batch of URLs, but create a fresh context when cookies, locale, or viewport settings must be isolated.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Network idle is a useful hint for applications that finish with a quiet connection, but analytics, advertisements, and long polls can prevent it from ever occurring. A selector or item count tied to the page’s own state is usually a better completion criterion. Capture retries should be bounded and should preserve the original error and URL for diagnosis.

There is no generally valid speed or reliability benchmark between Playwright and Puppeteer in the cited material. Measure your own pages, browser version, viewport, and concurrency. For an unbounded feed, define the exact number of items or screens that the business needs before estimating runtime and storage.

Common failures and fixes

Symptom Likely cause Fix
Blank images below the fold The page never received real viewport scroll events. Scroll in increments, wait after each step, and verify image completion before fullPage.
Height stops growing while more rows exist The content is in a nested scrolling element or is virtualized. Scroll the owning container; for virtualization, capture viewport segments or use an export.
The script loops forever An infinite feed continually appends content. Use a maximum iteration/time limit and stop at a defined item count.
Screenshot contains a consent dialog Consent is required before scripts or images run. Click the site’s consent control before scrolling, then wait for the overlay to disappear.
Timeout at waitForSelector The selector is wrong, the user is unauthenticated, or the page failed. Inspect the HTML and response status, verify cookies/headers, and choose a readiness signal that actually exists.
Rows repeat or disappear A virtualized list recycles DOM nodes. Capture each viewport while scrolling or obtain data from the application’s supported export.
Images remain broken A third-party request failed, was blocked, or needs more time. Log failed requests, allow required resources, wait for a bounded period, and mark the output incomplete when necessary.
Independent reader supportYour contribution helps us test, update, and keep practical guides available for everyone.Support on Ko-Fi

Or skip the browser setup

ScreenshotNeo is a hosted screenshot API and MCP server. It performs the browser work for a URL and returns PNG, JPEG, WebP, or PDF. Its clean-shot mode accepts cookie and consent banners before capture and removes more than 60 known consent platforms, newsletter popups, and chat widgets; each step can be disabled. Bot checks or CAPTCHAs, blank pages, timeouts, failed loads, and cache hits are not billed, and every response identifies the result with X-Page-Verdict and X-Billed headers.

For a Node.js call, see the ScreenshotNeo API documentation:

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
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(`ScreenshotNeo request failed: ${res.status}`);
const data = Buffer.from(await res.arrayBuffer());
require('fs').writeFileSync('shot.webp', data);

The equivalent requests are:

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)
r.raise_for_status()
open("shot.webp", "wb").write(r.content)

ScreenshotNeo exposes 63 options, including full-page capture with lazy images loaded, CSS-selector element capture, dark mode, 12 device presets or a custom viewport, retina scale, PDF paper size/margins/orientation/page ranges, HTML/CSS-to-image, custom JavaScript and CSS, pre-capture clicks, hidden selectors, waits for a selector, delay or network idle, ad/tracker/request/resource blocking, custom headers, cookies, user agents and Authorization, timezone and geolocation, transparent backgrounds, resizing, a chosen cache TTL, signed links for public images, asynchronous jobs with signed webhooks, bulk capture of up to 100 URLs per call, a usage API, an OpenAPI specification, and compatibility with parameter names used by other screenshot APIs.

AI workflows can use its MCP server with Claude, Cursor, or another MCP client. The tools are take_screenshot, get_page_info, and capture_pdf.

Plan Included shots Price
Free 1,000 per month $0, no card
Starter 3,000 $5
Growth 15,000 $15
Pro 60,000 $39
Scale 250,000 $99
Business 1,000,000 $249

Every feature is available on every plan, and yearly billing provides two months free. Start with 1,000 free screenshots a month with no card; paid plans start at $5 for 3,000.

FAQ

Should I restore the page’s original scroll position?

Yes, when the browser must remain interactive after capture. Save window.scrollY before the loading pass and call window.scrollTo(0, savedY) after the screenshot. For a one-shot batch worker that closes the page immediately, restoration has no practical effect.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Can I capture only one lazy-loaded component?

Yes. Scroll the component’s owning container until its required content is ready, then use Playwright’s locator screenshot or Puppeteer’s element screenshot. This avoids stitching unrelated page regions into the output.

How do I know whether a missing section is a bug in my script or in the site?

Run the page interactively with the same viewport and watch whether scrolling changes the DOM or network traffic. If manual scrolling also never reveals the section, the account, region, consent state, or application data may be the limiting factor rather than the screenshot call.

Frequently Asked Questions

Should I restore the page’s original scroll position?

Yes, when the browser must remain interactive after capture. Save window.scrollY before the loading pass and call window.scrollTo(0, savedY) after the screenshot.

Can I capture only one lazy-loaded component?

Yes. Scroll its owning container until the required content is ready, then use a locator or element screenshot instead of capturing the whole document.

Free tools Windows power users keep installed

One-click scans. No signup required.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

How do I distinguish a script bug from a site limitation?

Repeat the same viewport and scroll manually. If the section does not appear interactively, authentication, consent, regional data, or the application itself may be responsible.

Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon at the time of purchase will apply.