How to Screenshot a Web App After Its Virtualized List Finishes Loading
Wait for the app’s own completion signal, scroll the list’s actual container to render needed rows, verify the target state, then capture the right scope.
A reliable screenshot of a virtualized list starts with an application-specific readiness check, not a guess that the page has finished loading. Wait for a meaningful signal such as the loading indicator disappearing, a target row appearing, or an expected count being reached. If the list loads as you scroll, scroll its own container until the rows you need are rendered, verify the result, and then capture the viewport, list element, or page.
A full-page screenshot captures the page’s scrollable extent; it does not guarantee that a virtualized component has rendered every record. If you need every record, trigger each relevant range and capture overlapping sections, or use an app-supported export or render-all mode when available.
1. Identify what “finished loading” means
Virtualized lists usually render only the rows needed for the current view, sometimes with a small buffer. Rows outside that range may not exist as rendered page content until scrolling brings them into range. The correct readiness condition depends on the application and the screenshot’s purpose.
- One particular row: wait until that row is visible in the list.
- A known result set: wait for the displayed count or a known last row.
- Initial load: wait for the loading indicator to disappear and for at least one expected row to appear.
- Infinite scrolling: scroll the list’s owner and wait for new rows or a terminal indicator after each scroll.
Do not treat navigation completion or network quiet as proof that the list is complete. Applications may keep background requests open, or finish network activity before rendering and data processing are done. Playwright’s navigation guidance recommends assertions about the page’s actual state over using networkidle as a generic readiness signal (navigation documentation).
2. Find the list’s scroll owner
The document may scroll, or a nested panel may own scrolling. Inspect the app in browser developer tools: scroll the pointer over the list and see which element’s scrollTop changes. In the examples below, [data-testid="results-list"] is a placeholder; replace it with a stable selector from your app.
Scrolling the document will not necessarily trigger a list inside a nested panel. Likewise, a locator screenshot of a scrollable element captures the element’s currently scrolled content, rather than automatically combining all of its scroll positions. See the Playwright locator API.
3. Runnable Playwright example in JavaScript
This Node.js script opens a page, waits for an application-specific readiness signal, scrolls the list panel to its end, checks for a terminal marker, and captures the list. Replace the URL, selectors, and readiness logic with those for your app.
const { chromium } = require('playwright');
(async () => {
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
try {
await page.goto('https://your-app.example/results', { waitUntil: 'domcontentloaded' });
const list = page.locator('[data-testid="results-list"]');
const loading = page.locator('[data-testid="loading-indicator"]');
const endMarker = page.locator('[data-testid="end-of-results"]');
await list.waitFor({ state: 'visible', timeout: 15000 });
await loading.waitFor({ state: 'hidden', timeout: 30000 });
await page.locator('[data-testid="result-row"]').first().waitFor({ state: 'visible', timeout: 15000 });
// Scroll the list, not the document. The app may fetch or render more rows as it moves.
let previousCount = -1;
let stablePasses = 0;
for (let pass = 0; pass < 100; pass++) {
await list.evaluate(el => { el.scrollTop = el.scrollHeight; });
await page.waitForTimeout(250); // Short settling interval; replace with an app signal when possible.
const count = await page.locator('[data-testid="result-row"]').count();
const atEnd = await endMarker.isVisible().catch(() => false);
if (atEnd) break;
if (count === previousCount) stablePasses++;
else stablePasses = 0;
previousCount = count;
if (stablePasses >= 3) {
throw new Error('List stopped changing before the end marker appeared; confirm the app’s completion signal.');
}
if (pass === 99) throw new Error('Reached the scroll safety limit without finding the end marker.');
}
await endMarker.waitFor({ state: 'visible', timeout: 10000 });
// Return to the top if the desired screenshot should show the beginning of the list.
await list.evaluate(el => { el.scrollTop = 0; });
await page.waitForTimeout(100);
await list.screenshot({ path: 'loaded-list.png', animations: 'disabled' });
} finally {
await browser.close();
}
})().catch(err => {
console.error(err);
process.exitCode = 1;
});
Install the dependency with npm install playwright, then run the script with node capture.js. Install the browser binary if your environment does not already have it: npx playwright install chromium. The example deliberately throws if the list stops changing before an end marker appears. A repeated row count is not, by itself, proof that all data has loaded.
4. Choose the capture scope
| Method | Use it for | Limitation |
|---|---|---|
| Viewport screenshot | A particular visible state or debugging checkpoint | Rows outside the viewport are absent. |
| Locator or element screenshot | A list panel or other component | A scrollable element shows its current scrolled content, not every virtualized range. |
| Full-page screenshot | An ordinary page whose content is already rendered | Full scrollable extent does not cause a virtualized list to render all records. |
| Sequential overlapping captures | Long lists where every visible section matters | Check overlap to prevent gaps and duplicates. |
| App export or render-all mode | Complete data output where screenshot fidelity is secondary | Availability and behavior are app-specific. |
For a regular page screenshot, Playwright supports await page.screenshot({ path: 'page.png', fullPage: true }). That captures the page’s full scrollable extent, but does not establish that the app rendered every virtualized row (Playwright screenshot guide).
For a list panel, use await list.screenshot({ path: 'list.png' }). To capture every virtualized range, scroll and save multiple screenshots. Use an overlap larger than the tallest row, keep track of the first and last visible row in each image, and review joins for missing or repeated rows. If the app provides a supported render-all mode or export and completeness matters more than matching the on-screen view, consider that instead.
5. Python and cURL options
Playwright’s Python API follows the same workflow: wait on app state, operate the correct scroll container, verify, then capture. Install with pip install playwright and playwright install chromium.
import asyncio
from playwright.async_api import async_playwright
async def main():
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
page = await browser.new_page(viewport={"width": 1440, "height": 1000})
try:
await page.goto("https://your-app.example/results", wait_until="domcontentloaded")
list_box = page.locator('[data-testid="results-list"]')
loading = page.locator('[data-testid="loading-indicator"]')
end_marker = page.locator('[data-testid="end-of-results"]')
await list_box.wait_for(state="visible", timeout=15000)
await loading.wait_for(state="hidden", timeout=30000)
rows = page.locator('[data-testid="result-row"]')
await rows.first.wait_for(state="visible", timeout=15000)
previous_count = -1
stable_passes = 0
for pass_number in range(100):
await list_box.evaluate("el => { el.scrollTop = el.scrollHeight; }")
await page.wait_for_timeout(250)
count = await rows.count()
if await end_marker.is_visible():
break
stable_passes = stable_passes + 1 if count == previous_count else 0
previous_count = count
if stable_passes >= 3:
raise RuntimeError("List stopped changing before its end marker appeared")
else:
raise RuntimeError("Scroll safety limit reached without the end marker")
await end_marker.wait_for(state="visible", timeout=10000)
await list_box.evaluate("el => { el.scrollTop = 0; }")
await list_box.screenshot(path="loaded-list.png", animations="disabled")
finally:
await browser.close()
asyncio.run(main())
The source for this workflow is Playwright’s Python locator API and scrolling guide. The list selectors and completion marker are app-specific.
cURL alone cannot drive a browser’s scroll position or wait for client-side virtualized rows. It can request an app API if you know the endpoint and are authorized to use it, but that produces data rather than a browser screenshot. For a screenshot request without local browser orchestration, see the ScreenshotNeo option below.
6. Wait and stability options
- Prefer locator assertions: wait for a target row, count, loading state, or terminal marker. Playwright documents that
locator.all()returns immediately and can be unpredictable while a list changes (locator API). - Use scrolling deliberately: Playwright’s input guide describes manual scrolling to force an infinite list to load more items or to position a page for a screenshot.
- Disable animations for capture:
animations: 'disabled'on locator screenshot stops CSS animations, transitions, and Web Animations for the capture. This helps reduce visual variation; it does not make the data complete. - Use bounded waits: set timeouts that fit the app and environment, and fail with a useful message when the readiness condition is not met. Avoid unbounded scrolling loops.
- Wait for app state, not just a quiet network: network completion and UI completion are different conditions.
7. Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Screenshot contains only the first few rows | The screenshot captured the current virtualized range. | Scroll the list container before capture. For every row, capture sequential ranges or use an app-supported export/render-all path. |
| Scrolling the page loads no more rows | A nested panel owns scrolling. | Identify the element whose scrollTop changes and scroll that element. |
| Script captures a loading skeleton | Navigation or network completion was mistaken for UI readiness. | Wait for the loading indicator to disappear and assert a meaningful row, count, or status. |
| List stops changing but expected data is missing | Stable row count was treated as completion, or the app reached an error/empty state. | Require an explicit end marker, expected count, or known last row; surface app errors instead of silently capturing. |
locator.all() returns too few rows |
It does not wait for matches and samples the current state. | Wait for the list to stabilize against an app condition first; then enumerate if needed. |
| Full-page image is unexpectedly short | The document’s scrollable extent is short because the list is inside a panel, or offscreen rows are not rendered. | Capture the panel at specific scroll positions or drive the app to render the needed content. |
| Screenshots differ between runs | Rows, images, fonts, or animations are still settling. | Wait for target content and image readiness, disable animations, use a consistent viewport and device scale factor, and compare repeated captures only after data readiness. |
| Script times out waiting for the end | The app has no terminal marker, loading is slow, or the selector is wrong. | Inspect the live DOM and app behavior; replace the marker with the real completion condition and adjust a bounded timeout. |
8. Performance, reliability, and cost
Scrolling and saving each range takes time proportional to the number of ranges, and overlapping captures increase image storage and review work. Capture only the region and number of states the task requires. If the purpose is a regression check, a stable viewport or component image is usually easier to compare than a tall composite.
Reliability comes from making completion explicit: assert the expected row or end condition, keep scroll loops bounded, and fail visibly when the condition is absent. A fixed delay can help allow rendering to settle, but it cannot prove completeness. Network-idle waits can also be misleading when the app has background activity or deferred rendering.
Local Playwright’s direct cost is the infrastructure and time needed to run the browser and store the screenshots; the dossier provides no benchmark or cost figure. A screenshot API can avoid maintaining browser capture code, but it cannot infer a target app’s hidden completion condition. If that app must be scrolled through a custom sequence before capture, automation of that app-specific workflow is still needed.
9. Or skip the browser setup
ScreenshotNeo is a website screenshot API and MCP server. One GET request returns an image or PDF; for this list page, capture the desired URL after the app can present the state you need. ScreenshotNeo can capture full pages and can wait for a selector, a delay, or network idle, but a virtualized app’s own completion condition and scroll behavior remain app-specific. See the ScreenshotNeo API documentation.
curl -G "https://api.screenshotneo.com/v1/shot" \
-d access_key=YOUR_API_KEY \
--data-urlencode url=https://your-app.example/results \
-o shot.webp
import requests
r = requests.get(
"https://api.screenshotneo.com/v1/shot",
params={"access_key": "YOUR_API_KEY", "url": "https://your-app.example/results"},
timeout=90,
)
r.raise_for_status()
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({
access_key: 'YOUR_API_KEY',
url: 'https://your-app.example/results'
});
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`Screenshot request failed: ${res.status}`);
await import('node:fs/promises').then(fs => fs.writeFile('shot.webp', Buffer.from(await res.arrayBuffer())));
ScreenshotNeo removes cookie and consent banners, newsletter popups, and chat widgets before the shot. Bot checks, blank pages, and failed loads 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 screenshots.
Sign up free for 1,000 screenshots a month, with no card required.
10. FAQ
Can a single full-page screenshot include every virtualized row?
Not necessarily. It captures the page’s scrollable extent, while a virtualized component may render only the rows near its current position. Drive the app through the required ranges or use an app-provided complete rendering/export path.
Should I use networkidle to decide the list is ready?
Use a UI condition tied to the desired content instead. Network activity can continue after useful content appears or stop before the application has finished rendering it.
Does a list screenshot automatically scroll to the bottom?
No. A locator screenshot captures the element’s current rendered state. Scroll it explicitly and verify the target rows before taking the image.
How do I capture every row without gaps?
Scroll through the list in overlapping steps, record which rows each image contains, and inspect the joins. When the app supports a render-all or export function, that may provide a more dependable complete result.


