Playwright Screenshot of an Infinite Scroll Page With a Maximum Scroll Limit
Use a bounded Playwright scroll loop to load an infinite feed, stop at a clear limit, and capture the result without scrolling forever.
Use a loop with an explicit stopping condition, scroll the page or the feed’s actual scroll container, wait for the site’s content to load, and then take the screenshot. A maximum step count or deadline guarantees that automation cannot scroll forever. Add a no-growth condition to stop early when the feed appears finished.
page.screenshot({ fullPage: true }) captures the page’s current scrollable content. It does not itself scroll through an infinite feed to trigger more content. Load the content you want first, then capture. Playwright describes a full-page screenshot as a screenshot of the full scrollable page, as if it fit on a very tall screen. Playwright screenshot documentation
1. Install Playwright and choose your limits
This example uses Playwright’s Node.js library. Set the target URL, feed container selector, and item selector to match the page. The defaults cap traversal at 20 steps and stop after three checks with no new items. Those are example limits, not universal values.
mkdir bounded-feed-shot
cd bounded-feed-shot
npm init -y
npm install playwright
npx playwright install chromium
Save the following as capture-feed.js and run it with node capture-feed.js. It works with either a window-scrolling page or a nested scroll container. If there is no feed container, it scrolls the window.
2. Complete runnable Playwright example
const { chromium } = require('playwright');
const TARGET_URL = 'https://example.com/feed';
const FEED_SELECTOR = '[data-testid="feed"]'; // Set to null for window scrolling.
const ITEM_SELECTOR = '[data-testid="feed-item"]';
const OUTPUT_PATH = 'feed.png';
const MAX_STEPS = 20; // Hard safety cap.
const MAX_DURATION_MS = 30_000; // Second hard cap.
const STABLE_CHECKS_TO_STOP = 3;
const FALLBACK_WAIT_MS = 500; // Heuristic only; prefer a page-specific signal.
(async () => {
const browser = await chromium.launch({ headless: true });
try {
const page = await browser.newPage({ viewport: { width: 1365, height: 900 } });
await page.goto(TARGET_URL, { waitUntil: 'domcontentloaded', timeout: 30_000 });
const feed = FEED_SELECTOR ? page.locator(FEED_SELECTOR) : null;
if (feed) await feed.waitFor({ state: 'visible', timeout: 10_000 });
const startedAt = Date.now();
let previousCount = await page.locator(ITEM_SELECTOR).count();
let stableChecks = 0;
for (let step = 0; step < MAX_STEPS; step++) {
if (Date.now() - startedAt >= MAX_DURATION_MS) {
console.log('Stopping at time limit.');
break;
}
if (stableChecks >= STABLE_CHECKS_TO_STOP) {
console.log('Stopping because the item count stopped growing.');
break;
}
// Record the scroll position so a page at its bottom can stop promptly.
const before = feed
? await feed.evaluate(el => ({ top: el.scrollTop, height: el.scrollHeight, client: el.clientHeight }))
: await page.evaluate(() => ({ top: window.scrollY, height: document.documentElement.scrollHeight, client: window.innerHeight }));
if (feed) {
await feed.evaluate(el => { el.scrollTop += el.clientHeight; });
} else {
await page.evaluate(() => window.scrollBy(0, window.innerHeight));
}
// Replace this heuristic with an app-specific response, item, or loading-state wait when possible.
await page.waitForTimeout(FALLBACK_WAIT_MS);
const currentCount = await page.locator(ITEM_SELECTOR).count();
stableChecks = currentCount > previousCount ? 0 : stableChecks + 1;
previousCount = currentCount;
const after = feed
? await feed.evaluate(el => ({ top: el.scrollTop, height: el.scrollHeight, client: el.clientHeight }))
: await page.evaluate(() => ({ top: window.scrollY, height: document.documentElement.scrollHeight, client: window.innerHeight }));
console.log(`step=${step + 1} items=${currentCount} scrollTop=${Math.round(after.top)}`);
// No movement and no new content usually means the scroll surface reached its end.
if (after.top <= before.top && currentCount <= previousCount) {
// Keep the stability checks as the primary stop condition; one blocked move can be transient.
stableChecks++;
}
}
await page.screenshot({ path: OUTPUT_PATH, fullPage: true });
console.log(`Saved ${OUTPUT_PATH}`);
} finally {
await browser.close();
}
})().catch(error => {
console.error(error);
process.exitCode = 1;
});
The loop has two firm bounds: MAX_STEPS and MAX_DURATION_MS. The stable item-count check is an early-stop heuristic. For pages that can replace existing rows or update content without changing item count, use a better page-specific signal such as the feed’s cursor, a loading indicator, or a known end marker.
Playwright supports controlled scrolling with the mouse wheel or by evaluating scrollTop on a locator. Its scrolling guidance recommends identifying the element you need at the bottom and scrolling it into view when that is the reliable way to trigger an infinite list. Playwright scrolling guidance
3. Make the stopping condition match the page
| Stop rule | Good fit | Tradeoff |
|---|---|---|
| Maximum steps | Repeatable capture or a known traversal depth | May stop before reaching a particular item if loading is slow |
| Deadline | Jobs with a strict runtime budget | Completion depends on network and page speed |
| Target item count | Capturing a known number of cards or rows | Requires a reliable selector and count semantics |
| Stable count or height | Feeds that stop growing at an end | Temporary pauses can look like completion; retain a hard cap |
| End marker | Applications expose an explicit end-of-results element | Only works if the application provides one |
Use a hard cap even when an end marker or stable-content rule is available. A network retry, endlessly replenished feed, or broken selector should not turn a screenshot job into an unbounded browser session.
Wait for a real loading signal when available
A fixed delay is simple but cannot prove that content loaded. If the application exposes a loading indicator, wait for it to appear and disappear, or wait for the item count to increase:
const beforeCount = await page.locator(ITEM_SELECTOR).count();
await page.evaluate(() => window.scrollBy(0, window.innerHeight));
// Example only: adapt to the application's actual loading indicator.
await page.locator('[data-testid="loading"]').waitFor({ state: 'visible', timeout: 2_000 }).catch(() => {});
await page.locator('[data-testid="loading"]').waitFor({ state: 'hidden', timeout: 10_000 }).catch(() => {});
await page.waitForFunction(
({ selector, before }) => document.querySelectorAll(selector).length > before,
{ selector: ITEM_SELECTOR, before: beforeCount },
{ timeout: 10_000 }
).catch(() => {});
If the site uses a known API request for pagination, you can wait for that response instead. Match the application’s actual endpoint and response contract; a generic network-idle wait may never settle on pages with analytics, polling, or long-lived connections.
4. Capture a full page, a feed element, or bounded slices
Full-page image after loading
await page.screenshot({ path: 'feed.png', fullPage: true });
This is appropriate when the loaded content remains in the DOM and the resulting image height is manageable. Full-page capture does not guarantee scroll-triggered items were loaded before the capture.
Capture the feed element
await page.locator('[data-testid="feed"]').screenshot({ path: 'feed-element.png' });
An element screenshot captures that element’s visible bounding box. For a nested scrolling list, it will not automatically represent every off-screen row inside the scroll container. Traverse and capture slices if you need a visual record of all rows.
Capture viewport slices for a virtualized list
Virtualized feeds reuse a small number of DOM rows as you scroll. A final full-page screenshot may contain only the rows currently rendered, not all rows traversed. Capture each viewport during the bounded loop when the goal is to preserve the whole visual sequence:
const slices = [];
for (let step = 0; step < MAX_STEPS; step++) {
// Scroll and wait using the same bounded logic as above.
await page.evaluate(() => window.scrollBy(0, window.innerHeight));
await page.waitForTimeout(FALLBACK_WAIT_MS);
slices.push(await page.screenshot());
}
// Each entry in slices is a PNG buffer; save or combine them using your own output pipeline.
For production, apply the same time cap, no-growth rule, and bottom detection to this loop. Avoid blindly stitching overlapping slices: sticky headers, changing layouts, and row recycling can create duplicates or gaps.
5. Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Screenshot has only the initial items | The capture happened before actual scrolling triggered loading | Scroll first, then wait for a new item or application loading signal before capture |
fullPage: true is still short |
Content was never loaded, or the page has a nested scroll area | Scroll the correct surface and confirm item count before taking the full-page screenshot |
window.scrollBy() does nothing |
The feed scrolls inside an element | Find the scrollable container and update its scrollTop or hover it and use mouse.wheel() |
| Loop stops while the feed is still loading | The stable-count threshold is too eager or the wait is too short | Wait for a specific request/state; raise the stability threshold while retaining the hard caps |
| Loop keeps going at the end | Items are re-rendered, selectors are wrong, or the feed never has a terminal state | Check actual scroll movement and use an end marker, cursor, or deadline |
| Rows are missing from the tall image | The feed virtualizes rows and removes off-screen items from the DOM | Capture viewport slices during traversal or use an application data export for complete records |
| Navigation times out | The page does not reach the selected load state within the timeout | Use domcontentloaded when suitable, set an intentional navigation timeout, then wait for the page-specific feed readiness condition |
| Browser closes before screenshot finishes | Cleanup runs before awaited operations finish, or an exception bypasses sequencing | Await each capture and close the browser in a finally block |
6. Performance, reliability, and cost
- Bound total work: A step cap bounds scrolling actions; a deadline bounds the overall loop. Also set navigation and per-signal timeouts.
- Keep the capture size practical: Tall screenshots use more memory and take longer to encode and store. Prefer slices when a feed is very long or virtualized.
- Do not wait longer than needed: A page-specific signal is usually more efficient and dependable than repeatedly sleeping for a large fixed interval.
- Make runs diagnosable: Log the step, item count, elapsed time, and final stop reason. Save a trace or diagnostic screenshot when investigating flaky runs.
- Expect variation: Content, ads, network latency, and viewport size can change the number and appearance of items. Use a stable test account and deterministic fixtures for repeatable visual checks where possible.
- Account for browser resources: Close contexts and browsers even on errors. Limit concurrent captures to the memory and CPU capacity of the host.
- Cost: Self-hosted Playwright has no per-screenshot API charge, but browser compute, storage, and engineering time still have costs. A bounded loop makes runtime more predictable.
7. Or skip the browser setup
ScreenshotNeo is a website screenshot API and MCP server. One GET request returns an image or PDF. It is useful when the goal is a page capture without maintaining a browser script; this API call does not implement the custom infinite-scroll traversal above.
Cookie banners are accepted like a visitor and removed along with known consent platforms, newsletter popups, and chat widgets before the shot. Bot checks, blank pages, and failed loads are never billed, and response headers report the page verdict and billing status. An MCP server lets AI agents use take_screenshot, get_page_info, and capture_pdf. The free plan includes 1,000 screenshots a month with no card; paid plans start at $5 for 3,000.
See the ScreenshotNeo API documentation and try a capture:
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}`);
const fs = await import('node:fs/promises');
await fs.writeFile('shot.webp', Buffer.from(await res.arrayBuffer()));
Sign up for 1,000 free screenshots a month with no card.
8. FAQ
Does Playwright have a maximum-scroll screenshot option?
The documented screenshot API provides fullPage, but no maximum-scroll setting. Add the limit in your own scrolling loop.
Should I use a fixed number of scrolls or stop when the feed ends?
Use a hard maximum for safety, plus a page-specific completion signal or stable-content rule for early stopping.
Can one full-page screenshot include every row in a virtualized feed?
Not necessarily. If rows are removed from the DOM as they leave the viewport, capture bounded slices while traversing.


