How to capture a full-page screenshot of a page with lazy-loaded images
Scroll the live page before capturing it: a full-page screenshot can include offscreen space without triggering the lazy-loaded images there.
To capture a full-page screenshot with lazy-loaded images, scroll through the live page in steps, wait for each newly visible section to load, then take the full-page capture. A full-page option expands the screenshot area; it does not necessarily scroll the page in a way that activates content tied to viewport visibility. Check the saved image afterward for missing images or sections.
This distinction matters because native lazy loading and many JavaScript loading patterns defer work until content approaches the viewport. Chrome describes loading="lazy" as deferring a resource until it reaches a calculated distance from the viewport. That distance can vary, so a fixed scroll interval or delay is not reliable for every site. Chrome Developers: browser-level image lazy loading.
Why a full-page capture can miss images
A screenshot API’s fullPage option captures the full scrollable page. Playwright documents that behavior, but it does not promise that the page’s own scroll-triggered code has run for every offscreen section. In practice, expanding the capture area and scrolling the actual viewport are separate operations. Playwright screenshot API.
Pages can defer images with HTML’s loading="lazy", JavaScript, or IntersectionObserver. They may also append new content near the bottom. A virtualized feed is a special case: it may only keep nearby rows in the document, so scrolling can replace earlier rows instead of building one tall page. CSS background images do not use the HTML image loading attribute.
Manual workflow in a browser
- Open the page and wait for its initial layout to settle.
- Scroll down in increments. Pause when needed so images and scroll-triggered content can load. Continue until you reach the end or the page stops growing.
- If the capture control depends on the starting position, return to the top.
- Capture the entire page using your browser’s full-page screenshot feature.
- Inspect the image from top to bottom. Look for blank image rectangles, missing sections, or repeated/missing list items.
Firefox offers a built-in full-page capture from its screenshot interface. Depending on your Firefox version and configuration, open the screenshot tool and choose Save full page. Mozilla documents this workflow in its Firefox screenshot guide. Scroll the page first when its content depends on viewport-triggered loading.
Automate the capture with Playwright
Install Playwright and its Chromium browser, save the following as screenshot.mjs, then run node screenshot.mjs https://example.com. The loop rechecks the page height because some sites append content as you approach the bottom. The 300 ms pause is only a starting value; increase it or wait for known page-specific content when necessary.
import { chromium } from 'playwright';
const target = process.argv[2];
if (!target) throw new Error('Usage: node screenshot.mjs https://example.com');
const browser = await chromium.launch({ headless: true });
try {
const page = await browser.newPage({ viewport: { width: 1365, height: 900 } });
await page.goto(target, { waitUntil: 'domcontentloaded', timeout: 60000 });
// Scroll the live viewport so lazy and scroll-triggered content can load.
await page.evaluate(async () => {
const pause = ms => new Promise(resolve => setTimeout(resolve, ms));
const step = Math.max(300, Math.floor(window.innerHeight * 0.8));
let previousHeight = 0;
let stablePasses = 0;
const maxPasses = 100;
for (let pass = 0; pass < maxPasses && stablePasses < 3; pass++) {
const height = document.documentElement.scrollHeight;
for (let y = 0; y < height; y += step) {
window.scrollTo(0, y);
await pause(300);
}
await pause(300);
const newHeight = document.documentElement.scrollHeight;
stablePasses = newHeight === previousHeight ? stablePasses + 1 : 0;
previousHeight = newHeight;
}
window.scrollTo(0, 0);
await pause(300);
});
// Optional check: report images still not complete or without a natural size.
const pending = await page.locator('img').evaluateAll(images =>
images.filter(img => !img.complete || img.naturalWidth === 0)
.map(img => img.currentSrc || img.src)
);
if (pending.length) console.warn('Images still incomplete:', pending);
await page.screenshot({ path: 'page.png', fullPage: true });
} finally {
await browser.close();
}
Install and run:
npm install playwright
npx playwright install chromium
node screenshot.mjs https://example.com
The image check is diagnostic, not proof that every visible image has the right content. Some pages intentionally leave images unloaded, use CSS backgrounds, or use a virtualized list. If you know the expected image selector, wait for those specific images and verify their natural dimensions before capture.
Playwright options that affect the result
fullPage: truecaptures the full scrollable page; it does not replace the scroll pass.pathchooses the output file. The extension can determine the format.typesupports PNG, JPEG, or WebP. JPEG and WebP support aqualitysetting; PNG does not.scale: 'css'produces one image pixel per CSS pixel;scale: 'device'uses device pixels and can create a larger image.timeoutsets the screenshot operation timeout. Navigation has its own timeout.stylecan apply CSS while capturing, useful for hiding volatile elements, but should not hide content you need to inspect.
See the Playwright API reference for the full option list and current details.
Use Puppeteer with the same scroll-before-capture approach
Puppeteer also supports a full-page screenshot. Install it and Chromium, save this as screenshot.cjs, then run node screenshot.cjs https://example.com. As with Playwright, full-page capture does not guarantee a target page’s lazy content has rendered.
const puppeteer = require('puppeteer');
(async () => {
const target = process.argv[2];
if (!target) throw new Error('Usage: node screenshot.cjs https://example.com');
const browser = await puppeteer.launch({ headless: true });
try {
const page = await browser.newPage();
await page.setViewport({ width: 1365, height: 900 });
await page.goto(target, { waitUntil: 'domcontentloaded', timeout: 60000 });
await page.evaluate(async () => {
const pause = ms => new Promise(resolve => setTimeout(resolve, ms));
const step = Math.max(300, Math.floor(window.innerHeight * 0.8));
let previousHeight = 0;
let stablePasses = 0;
for (let pass = 0; pass < 100 && stablePasses < 3; pass++) {
const height = document.documentElement.scrollHeight;
for (let y = 0; y < height; y += step) {
window.scrollTo(0, y);
await pause(300);
}
await pause(300);
const newHeight = document.documentElement.scrollHeight;
stablePasses = newHeight === previousHeight ? stablePasses + 1 : 0;
previousHeight = newHeight;
}
window.scrollTo(0, 0);
await pause(300);
});
await page.screenshot({ path: 'page.png', fullPage: true });
} finally {
await browser.close();
}
})();
npm install puppeteer
node screenshot.cjs https://example.com
Puppeteer documents Page.screenshot() and its screenshot options in the API reference. Choose a bounded navigation timeout and close the browser in a finally block so a failed page does not leave a process running.
Handle pages that keep loading or virtualize content
- Growing page: repeat the scroll pass while checking
document.documentElement.scrollHeight. Use a maximum number of passes, as in the examples, to avoid an endless loop on an infinite feed. - Known content: wait for a selector that identifies the expected section or image. A generic network-idle condition may never occur on pages with analytics, polling, or persistent connections.
- Slow images: after scrolling, wait for specific image elements to become complete and have a nonzero
naturalWidth. A timeout should turn a slow or broken resource into a diagnosable result instead of an unbounded wait. - Virtualized list: if offscreen rows disappear as new rows enter the viewport, one full-page image may not contain the whole feed. Capture separate viewport ranges or use the page’s export/data route if available.
- Interaction-gated content: a click, consent choice, tab switch, or expand action may be needed before a section exists. Perform the intended interaction before the scroll pass.
- Hidden images: an image hidden with
display: nonemay not load while it remains hidden. Reveal the relevant page state before expecting it in the capture.
Performance, reliability, and output size
Each scroll step adds waiting time, so a slow page or a long document can take significantly longer than a viewport screenshot. Use a viewport-sized step with some overlap, wait only as long as the target page needs, and prefer known selectors or image-completion checks over an arbitrarily long delay. The example’s repeated height checks make room for appended content while its pass limit bounds runtime.
Very tall pages can produce large image files and may stress browser or image-decoder memory. Reduce screenshot scale when CSS-pixel output is sufficient, or capture in sections when the destination cannot handle one extremely tall image. Use PNG for lossless detail; JPEG or WebP with an appropriate quality can reduce file size when slight compression is acceptable. Verify the result visually because successful API completion only means a file was produced, not that every deferred resource loaded.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Blank image areas below the fold | The screenshot expanded the capture area without triggering viewport-based loading, or the wait was too short. | Scroll in smaller steps, wait longer near the affected section, then capture again. Check whether the image request failed. |
| The page gets taller during capture | Content is appended as the bottom approaches. | Recheck document height and repeat the pass until it stabilizes; keep a maximum pass count for feeds that never end. |
| Only nearby list items appear | The page may virtualize rows and remove items outside the viewport. | Capture viewport segments or use a page-specific export. A single full-page screenshot may not represent all feed items. |
| An image stays blank while hidden | Its element may be hidden, or its source is set only after a script or interaction. | Reveal the relevant state, inspect src, srcset, and currentSrc, and trigger the site’s intended interaction. |
| Waiting for network idle hangs | Analytics, polling, or persistent connections keep network activity open. | Use domcontentloaded for navigation, then wait for specific content or image completion with a timeout. |
| Screenshot times out or browser exits early | The page is unusually tall, resources are slow, or capture is attempted before the scroll pass finishes. | Increase the relevant timeout, reduce scale, capture sections, and await each scroll and screenshot operation. |
| Timing changes between runs | Lazy-loading distance and resource timing depend on the browser, network, and page code. | Do not assume one hard-coded distance works universally. Verify expected images and inspect each output. |
Or skip the browser setup
ScreenshotNeo is a website screenshot API and MCP server. Its full-page capture loads lazy images. For a one-call image capture, replace the sample URL and API key below. 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://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);
Cookie banners, newsletter 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. 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, no card required.
FAQ
Does fullPage: true scroll the page?
It requests a capture of the full scrollable page. Run a separate scroll pass first when the page loads content in response to viewport movement.
How long should I wait at each scroll position?
There is no universal delay. Start with a short pause, inspect the output, then increase the wait or wait for known selectors and images on pages that load slowly.
Will this work for CSS background images?
The HTML image loading attribute does not control CSS backgrounds. Scroll-triggered styles may still reveal them, but check the page’s CSS and output when a background is missing.
Can one screenshot include every item in an infinite feed?
Not necessarily. A feed may never end or may render only the rows near the viewport. Capture bounded sections or use a data/export method when you need every item.


