How to Capture a Complete Screenshot of a React Page with Infinite Scrolling
Learn how to scroll a React feed until the intended items load, handle virtualized lists, and capture and verify a complete screenshot with Playwright.
To capture a complete screenshot of an infinite-scrolling React page, first scroll the real page or its feed container until the specific items you want have loaded. Then capture the rendered document with Playwright’s fullPage: true option. That option captures the current scrollable document; it does not itself scroll the viewport, trigger more feed requests, or guarantee that every item in an unbounded feed is present.
Define what “complete” means before you start: for example, the first 100 items, all entries through a known ID, or everything before an explicit end-of-results marker. Wait for a page-specific signal that confirms the target is loaded, and inspect the resulting image to confirm the final intended item appears.
1. Understand what a full-page screenshot captures
Playwright’s fullPage: true produces a screenshot of the full scrollable page as one tall image. It captures the document as currently rendered. It is not equivalent to a visitor scrolling through the page.
That difference matters for React feeds that load more content when the viewport reaches a trigger. Lazy images, iframes, content loaded through IntersectionObserver, scroll-triggered reveals, and virtualized rows may be missing if the page is captured without real scrolling. See the [Playwright full-page screenshot documentation](https://playwright.dev/docs/screenshots#full-page-screenshots) and the [Playwright issue describing viewport-dependent capture behavior](https://github.com/microsoft/playwright/issues/12962).
| Page behavior | Recommended approach | Important limitation |
|---|---|---|
| All target content is already in the document | Capture with fullPage: true. |
Verify the output includes the expected last item. |
| Content loads as you scroll | Scroll in steps, wait for each batch or completion signal, then capture. | A scroll step needs time and an observable page response. |
| Rows are virtualized | Render all target rows in a test setup, capture viewport segments, or export the underlying data. | Old offscreen rows may be removed from the DOM, so one full-page screenshot cannot include them all. |
| The feed is genuinely unbounded | Choose a finite boundary such as a count or item ID. | There is no natural “complete” state without a defined stopping point. |
2. Prepare the React page and choose a completion signal
Navigate to the route and wait for a page-specific starting condition, such as the feed container or first item. A useful completion signal is one that corresponds to the content you need, not merely that the browser has become quiet.
- Known item count: stop when the number of rendered feed items reaches the target.
- Known final item: stop when an item with the expected ID appears.
- End marker: stop when the app displays an explicit end-of-results element.
- Stable batch: for an inherently unbounded feed, define a finite target and use the app’s loading state and item count to confirm it.
Do not rely on networkidle as the only readiness check. Playwright marks that wait condition as discouraged for general readiness and recommends checking whether the page is actually ready for the task. See [Playwright’s page navigation documentation](https://playwright.dev/docs/api/class-page#page-goto).
3. Runnable Playwright example for a body-scrolling feed
The example below assumes the page scrolls at the document level, feed rows match [data-testid="feed-item"], and the target is a known item count. Replace the URL, selector, and count with values from your app. It uses bounded waits so a broken or endless feed fails with a useful error instead of looping forever.
import { chromium } from 'playwright';
const url = 'http://localhost:3000/feed';
const itemSelector = '[data-testid="feed-item"]';
const expectedCount = 100;
const outputPath = 'react-feed.png';
const maxScrolls = 200;
const batchTimeoutMs = 10_000;
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
try {
await page.goto(url, { waitUntil: 'domcontentloaded' });
await page.locator(itemSelector).first().waitFor({ state: 'visible', timeout: 15_000 });
for (let step = 0; step < maxScrolls; step += 1) {
const count = await page.locator(itemSelector).count();
if (count >= expectedCount) break;
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
try {
await page.waitForFunction(
({ selector, previous }) => document.querySelectorAll(selector).length > previous,
{ selector: itemSelector, previous: count },
{ timeout: batchTimeoutMs }
);
} catch {
const current = await page.locator(itemSelector).count();
throw new Error(
`Feed stopped advancing at ${current} items; expected ${expectedCount}. ` +
'Check the selector, loading state, authentication, and feed boundary.'
);
}
}
const finalCount = await page.locator(itemSelector).count();
if (finalCount < expectedCount) {
throw new Error(`Only ${finalCount} of ${expectedCount} requested items were rendered.`);
}
await page.screenshot({ path: outputPath, fullPage: true });
console.log(`Saved ${outputPath} with ${finalCount} rendered items.`);
} finally {
await browser.close();
}
Run it in a project with Playwright installed, for example by adding playwright as a development dependency and executing the file with a Node.js version that supports ES modules and top-level await. The code deliberately waits for the item count to increase after each scroll. If the application signals batch completion another way, replace that wait with an assertion against the app’s loading indicator or end marker.
4. Adapt the workflow for nested scroll containers
Some React feeds scroll inside a panel while the document itself stays still. Scrolling window in that case will not trigger the feed. Locate the scrollable element and move that element in increments:
const feedSelector = '[data-testid="feed-scroll-container"]';
const feed = page.locator(feedSelector);
for (let step = 0; step < maxScrolls; step += 1) {
const count = await page.locator(itemSelector).count();
if (count >= expectedCount) break;
await feed.evaluate(element => {
element.scrollTop = element.scrollHeight;
});
await page.waitForFunction(
({ selector, previous }) => document.querySelectorAll(selector).length > previous,
{ selector: itemSelector, previous: count },
{ timeout: batchTimeoutMs }
);
}
For a feed that only loads when its sentinel enters view, use a scroll increment that actually brings the sentinel into the viewport. Jumping directly to the bottom can work for many pages, but applications with staged thresholds or animation may need smaller increments. A short delay may help an app settle, but prefer waiting for a specific DOM or loading-state change when possible.
5. Handle virtualized lists and lazy-loaded media
Virtualized rows
Virtualized lists render only the visible rows plus a small buffer, then recycle or remove rows as the user scrolls. At the end of scrolling, the DOM may contain only the newest window of rows. A full-page screenshot operates on the rendered document; it cannot restore rows the app removed.
When every item must appear in one deliverable, consider these options:
- Use a test-only non-virtualized mode. Configure the component to render the finite target set at once, if the app supports that safely.
- Capture segments while scrolling. Save viewport-sized screenshots for each range, then stitch them with an image tool. Account for overlap and repeated sticky headers, and inspect the final composite.
- Use a data export for completeness. If the requirement is to preserve every record rather than reproduce the visual page, export the source data and render a dedicated static capture view.
These are workflow choices, not an automatic Playwright feature. The right choice depends on whether you need visual fidelity to the interactive feed or a complete representation of its data.
Lazy-loaded images and iframes
Scrolling through the target region gives viewport-based loading a chance to run. Before the final capture, wait for the media you care about to load. For example, you can check that visible images have completed loading:
await page.waitForFunction(() => {
const images = [...document.images];
return images.every(image => image.complete);
}, null, { timeout: 15_000 });
This check can time out if the page contains broken media or images that are intentionally not requested. For stricter validation, inspect the specific images in the target range and check their naturalWidth. Cross-origin iframe contents may have their own loading behavior; wait for the frame or visible content your capture requires.
6. Choose between one tall image and segmented captures
A single full-page image is convenient when the target content remains in the DOM and the page height is manageable. Segmented viewport captures provide more control for virtualized pages or pages with unstable sticky UI, but stitching introduces its own risks.
| Consideration | One tall image | Viewport segments |
|---|---|---|
| Content in DOM | Works when all target rows coexist in the rendered document. | Can preserve rows encountered at different scroll positions. |
| Sticky headers | Browser full-page behavior may not match a user’s scrolling view. | Headers may repeat; crop or account for overlap when stitching. |
| Layout changes | One capture can reflect a single final layout. | Content shifts between segments can create seams or duplicates. |
| Validation | Check image dimensions and the final expected item. | Check every segment boundary and the stitched image for gaps and duplicates. |
For browser automation already built around Chrome DevTools Protocol, Chrome also exposes Page.captureScreenshot. See the [Chrome DevTools Protocol Page domain](https://chromedevtools.github.io/devtools-protocol/tot/Page/#method-captureScreenshot). It provides a browser-level screenshot command; the same content-loading and virtualization considerations still apply.
7. Verify that the screenshot is complete
- Confirm the rendered item count or final item ID matches the boundary you chose.
- Check that the end marker is present if the app provides one.
- Open the image and inspect its bottom edge, not just whether a file was written.
- Confirm expected images and embedded content loaded.
- For segmented output, inspect overlap, sticky elements, missing ranges, and duplicated rows.
A successful screenshot call means an image was produced. It does not prove the feed finished loading. Likewise, network quiet alone does not prove your specific target item was rendered.
8. Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Screenshot ends after the first batch | fullPage captured the current document without triggering scrolling. |
Scroll the real page or feed container first and wait for new items. |
| Rows appear to vanish as scrolling continues | The list is virtualized and recycles offscreen DOM nodes. | Disable virtualization for a finite test capture, take segments, or use a data export. |
| Scroll loop times out on every batch | Wrong item selector, wrong scroll container, auth wall, failed request, or feed already at its end. | Inspect the live DOM and network/application state; verify the selector and define an end condition. |
| Loop never finishes | The feed has no finite end or expected count can never be reached. | Set a maximum scroll count and timeout; stop at a known count, ID, or end marker. |
| Some images are blank | Lazy media was not brought into view, loading has not finished, or a resource failed. | Scroll the media into view, wait for image completion, and validate the specific resource. |
Content is missing despite networkidle |
Network quiet was treated as proof that the feed target rendered. | Wait for an item count, ID, or app-specific ready state instead. |
| Nested feed does not move | The script scrolls the window while an inner element owns scrolling. | Set scrollTop on the feed container and check its dimensions. |
| Stitched image has repeated bars or gaps | Sticky UI or layout changes affected segment alignment. | Use measured overlap, crop repeated fixed elements, and review each seam. |
9. Performance, reliability, and cost
Long feeds take time because each batch may require a request, rendering, and media loading. Keep the browser viewport and device scale factor appropriate for the output: high pixel density and very tall pages increase image size and memory use. A bounded scroll count, batch timeout, and total runtime limit protect automation workers from endless feeds and stalled requests.
For reliable captures, use stable selectors intended for tests, isolate the target route and authentication setup, and record the stopping condition alongside the output. If the visual page is not the requirement, a data export is often more reliable than trying to render a huge interactive feed into a single bitmap.
With local Playwright automation, costs depend on your own compute, browser runtime, storage, and any application resources consumed by loading the feed. There is no universal capture price or runtime: it depends on page size, media, network conditions, and the chosen boundary.
Or skip the browser setup
For a normal page that is already rendered, ScreenshotNeo can return a screenshot with one API request. Its full-page option loads lazy images. This does not turn an unbounded feed into a finite one or recover virtualized rows removed from the DOM, so define and prepare the content you need first. See the ScreenshotNeo API documentation.
curl -G "https://api.screenshotneo.com/v1/shot" \
-d access_key=YOUR_API_KEY \
--data-urlencode url=https://stripe.com \
-d full_page=true \
-o shot.webp
import requests
r = requests.get(
"https://api.screenshotneo.com/v1/shot",
params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com", "full_page": "true"},
timeout=90,
)
r.raise_for_status()
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({
access_key: 'YOUR_API_KEY',
url: 'https://stripe.com',
full_page: 'true',
});
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);
ScreenshotNeo removes cookie and consent banners, newsletter popups, and chat widgets before the shot; bot checks, blank pages, failed loads, timeouts, and cache hits are never billed. Its 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 for free.
FAQ
Does fullPage: true scroll through an infinite feed?
No. It captures the current full scrollable document. Scroll first to trigger the content you need.
Can a truly infinite feed be captured completely?
Only after “complete” is given a finite meaning, such as a count or final item ID. An unbounded feed has no natural last item.
Why is a full-page screenshot missing old list entries?
The React list may be virtualized and keep only nearby rows in the DOM. Capture segments or render a finite non-virtualized view.
Is network idle enough to prove the page is ready?
No. Check the feed’s target item, count, or explicit ready state instead.


