Screenshot a Lazy-Loaded Page at a Fixed Viewport in Puppeteer
Set a fixed Puppeteer viewport, trigger lazy content, wait for a page-specific readiness condition, and capture just the visible viewport.
To screenshot a lazy-loaded page at a fixed viewport in Puppeteer, set the viewport before navigation, navigate, trigger any scrolling needed to load the target content, wait for a condition that proves the content you need is ready, then call page.screenshot({ fullPage: false }). A fixed viewport controls the visible capture area; it does not make lazy-loaded content load by itself. networkidle2 can be a useful navigation condition, but it does not guarantee that every lazy-loaded element has appeared. See Puppeteer’s screenshot guide and API references for viewport, selector waits, and function waits.
Runnable Puppeteer example
Start with this example, then replace the readiness selector and scroll behavior with ones that match the target site. The sample selector is illustrative: it must exist on the page and should represent the content that needs to be captured.
import puppeteer from 'puppeteer';
const url = 'https://example.com';
const outputPath = 'page.png';
const readySelector = '[data-page-ready="true"]'; // Replace with a real page marker.
const browser = await puppeteer.launch();
try {
const page = await browser.newPage();
// Set the viewport before navigation so the page loads at this size.
await page.setViewport({
width: 1280,
height: 800,
deviceScaleFactor: 1,
});
await page.goto(url, {
waitUntil: 'networkidle2',
timeout: 60_000,
});
// If the target content is below the fold, scrolling can trigger its lazy load.
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
// Wait for a target-specific marker, then return to the top for a viewport shot.
await page.waitForSelector(readySelector, {
visible: true,
timeout: 30_000,
});
await page.evaluate(() => window.scrollTo(0, 0));
await page.screenshot({
path: outputPath,
type: 'png',
fullPage: false,
});
} finally {
await browser.close();
}
The scroll-to-bottom step is only an example of a trigger. On some pages it is better to scroll a particular container, scroll through the document in increments, or scroll directly to the element that needs to load. Returning to the top before capture means the saved screenshot shows the initial viewport. Remove that return-to-top step if the desired viewport is elsewhere.
Install and run
- Use a current Node.js installation and create a project with ES module support, for example by setting
"type": "module"inpackage.json. - Install Puppeteer with
npm install puppeteer. Puppeteer downloads a compatible browser by default. If your environment supplies its own Chrome or Chromium, use its documented executable configuration and ensure the browser version is compatible with your installed Puppeteer. - Save the example as
screenshot.js, update the URL and readiness condition, then runnode screenshot.js.
Puppeteer documentation results consulted for this guide identify version 25.12.0. Check your installed version before depending on version-specific behavior or options.
Choose the viewport and screenshot scope
Viewport dimensions
Call page.setViewport() before page.goto(). This makes the intended width and height part of the page’s initial layout and responsive breakpoint selection. Puppeteer notes that some sites do not expect viewport changes and that changing isMobile or hasTouch can reload a page. Its documentation also states: “In the case of multiple pages in a single browser, each page can have its own viewport.” (Puppeteer Page.setViewport().)
| Setting | What it controls | Practical guidance |
|---|---|---|
width, height |
CSS viewport dimensions in pixels | Choose the viewport the page should render for, such as 1280 × 800. |
deviceScaleFactor |
Device pixel ratio used for rendering | Use 1 for standard scale; a higher value creates more output pixels for the same CSS viewport. |
isMobile, hasTouch |
Mobile and touch emulation behavior | Set these only when the target scenario needs them. Changing them can reload the page. |
fullPage |
Whether to capture the full document or only the viewport | Use false for a fixed viewport screenshot. Use true when the whole document is the intended output. |
fullPage defaults to false in the screenshot options, but setting it explicitly documents the intended scope. Full-page output is a different capture goal and can include content outside the original viewport. See ScreenshotOptions and Page.screenshot().
Make lazy-loaded content ready
Lazy loading is page-specific. An image or section may load only when it approaches the viewport, after a scroll event, after application data arrives, or when another interaction occurs. A selector wait is useful only when the selected element is a meaningful readiness signal; an element existing in the DOM does not prove that its image or content is complete.
Wait for a meaningful selector
Use waitForSelector() when the page exposes a stable marker for the content you need. Set visible: true if the element must be visible before capture. Puppeteer documents a default timeout of 30 seconds; specify a timeout that fits your page and job limits.
await page.waitForSelector('.article-loaded', {
visible: true,
timeout: 30_000,
});
Wait for a custom condition
Use waitForFunction() when readiness depends on a property rather than a single selector. For example, you can wait for a known image to finish loading. Pick the selector and condition from the actual page; this example does not imply that every site uses an image with this class.
await page.waitForFunction(() => {
const image = document.querySelector('.hero-image');
return image instanceof HTMLImageElement && image.complete && image.naturalWidth > 0;
}, { timeout: 30_000 });
For a page with several required images, wait for all of the specific image elements that matter. An image can have complete set even when it failed, so checking naturalWidth > 0 helps distinguish a successfully loaded image. For content rendered into a canvas or fetched into application state, wait for the application’s own ready marker instead.
Trigger the lazy load before waiting
If the required content is below the fold, first trigger the behavior that causes it to load. A simple page-wide scroll may work for ordinary document scrolling:
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
await page.waitForSelector('.required-section', { visible: true });
await page.evaluate(() => window.scrollTo(0, 0));
For multiple lazy sections, scroll through the page in steps and wait for the particular content after each trigger. For nested scrolling, scroll the container rather than window. For a screenshot of a lower section, scroll that section into the desired viewport and capture there. These are implementation strategies, not universal rules: Puppeteer’s cited documentation describes the APIs but does not define one lazy-load procedure that fits every site.
Viewport-only versus full-page capture
| Goal | Configuration | Important detail |
|---|---|---|
| Capture exactly the visible fixed viewport | fullPage: false |
Scroll to the desired position before capture; only that viewport is saved. |
| Capture the entire document | fullPage: true |
Full-page capture does not itself guarantee that below-the-fold lazy content was triggered and loaded. |
| Capture a specific element | Find the element and use its bounding box or supported element screenshot workflow | Wait for that element and its content first; this output is not a fixed viewport shot. |
If full-page output must include lazy content, trigger each relevant lazy region and confirm its readiness before capturing. Do not infer completeness from a long document height alone.
Readiness and navigation options
page.goto() accepts lifecycle conditions such as load, domcontentloaded, networkidle0, and networkidle2. The screenshot guide demonstrates networkidle2 as a navigation example. It is a navigation heuristic, not a guarantee that app-specific or scroll-triggered lazy content is ready. Some pages also keep network connections open, making network-idle conditions unsuitable.
- Use
domcontentloadedwhen you want to continue as soon as the document is parsed and will wait on a page-specific condition afterward. - Use
loadwhen the page’s load event is an appropriate initial checkpoint. - Use
networkidle2ornetworkidle0as a useful quiet-network checkpoint when the target site supports it, then still wait for the content needed in the screenshot. - Use a selector or function wait to define actual readiness; tune its timeout to the expected page behavior.
See Puppeteer’s page interaction guide for waiting and page interaction APIs.
Common errors and fixes
| Symptom | Likely cause | Fix |
|---|---|---|
| Screenshot contains a placeholder or missing lower-page content | The content never entered the loading region, or capture happened before it finished. | Trigger the required scroll behavior, then wait for a target-specific marker or image state. |
TimeoutError from waitForSelector() |
The selector does not exist, is wrong for this route, remains hidden, or the site did not finish loading it. | Inspect the live DOM, choose a stable marker, and use visible: true only when visibility is required. Set an appropriate timeout. |
| Navigation hangs on network idle | The page keeps requests active or does not reach the selected idle condition. | Use a suitable earlier lifecycle condition such as domcontentloaded, then wait for the content-specific condition. |
| Wrong responsive layout | The viewport was changed after navigation or dimensions do not match the intended breakpoint. | Set width and height before navigation and use the desired device scale factor. |
| Screenshot shows the wrong part of the page | The scroll position was not restored or positioned as intended before capture. | Scroll to the desired viewport immediately before calling screenshot(). |
| Image element exists but appears blank | The DOM node appeared before the image finished, or the image request failed. | Wait for complete and naturalWidth > 0, or use the page’s own image-ready state; investigate failed requests if it never succeeds. |
| Browser launch fails in a container | Browser dependencies, executable path, or runtime permissions are not set up for that environment. | Install the dependencies required by Puppeteer’s browser, or configure the executable path for a compatible installed Chrome/Chromium. |
Performance, reliability, and cost
One browser launch per screenshot is simple and isolated, but launching a browser is overhead. For a service producing many captures, reuse a browser process and create a fresh page per job, then close the page in a finally block. Keep job concurrency within the memory and CPU available to the host. Close the browser when the script exits.
Use a readiness condition tied to the required content instead of adding a large fixed sleep. Fixed delays make fast pages slower and may still be too short for slow pages. Set navigation and wait timeouts, and handle failures so a timed-out capture does not leave a browser process running. If a page is dynamic, allow enough time for its specific data and images while avoiding indefinite waits.
Higher deviceScaleFactor values increase output pixel dimensions and can increase image size and rendering work. Full-page captures can also consume more memory than viewport captures. Puppeteer itself has no per-screenshot API charge in this workflow; the operational costs are your compute, browser runtime, storage, and any infrastructure used to run the job.
Or skip the browser setup
ScreenshotNeo is a website screenshot API and MCP server. Its API accepts one GET request with a URL and can return PNG, JPEG, WebP, or PDF. For a straightforward capture, see the ScreenshotNeo API documentation:
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,
)
open("shot.webp", "wb").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', new Uint8Array(await res.arrayBuffer()));
Cookie and consent banners, newsletter popups, and chat widgets are removed before the shot; each cleanup step can be turned off. Bot checks, blank pages, timeouts, failed loads, and cache hits are never billed, and response headers report the page verdict and billing status. The MCP server provides take_screenshot, get_page_info, and capture_pdf tools for Claude, Cursor, and other MCP clients. The Free plan includes 1,000 shots per month with no card; paid plans start at $5 for 3,000 shots. These examples capture a URL; Puppeteer-specific scroll-and-wait logic remains useful when the page needs a custom readiness condition or a particular scroll position. Sign up for 1,000 free screenshots a month with no card.
FAQ
Does networkidle2 guarantee lazy-loaded images are ready?
No. It can be a useful navigation checkpoint, but lazy content may require scrolling or another site-specific trigger and readiness check.
Can each Puppeteer tab have its own fixed viewport?
Yes. Puppeteer documents that each page in a browser can have its own viewport.
Why use a selector wait if I already wait for navigation?
Navigation lifecycle events describe document or network state. A selector or custom function can express whether the particular content needed for the screenshot is ready.
Should I use a fixed delay?
Only when the page offers no better signal and the delay is an intentional fallback. A content-specific wait is more reliable across fast and slow loads.


