Playwright Screenshots of Pages with Load-on-Scroll Maps and Markers
Scroll the page or map container to trigger lazy markers, wait for a page-specific ready signal, then capture the viewport, map element, or full page.
To capture a page whose map or markers appear as you scroll, make Playwright perform real scrolling through the document or the map’s scrollable container, wait for a signal that the newly requested content is ready, and then capture the viewport or map element. Use fullPage only to choose a taller page image: it does not guarantee that scroll-triggered code ran.
The exact selectors and readiness condition depend on the site. A map might fetch markers when its container intersects the viewport, when the user pans or zooms, or when a request completes. Inspect the application and adapt the example below. Playwright’s screenshot guide covers viewport, element, and full-page screenshots; its Page API documents the full-page option.
1. Choose what to capture
| Capture | Use it for | Playwright call |
|---|---|---|
| Current viewport | The visible state after you scroll to the desired position. | page.screenshot() |
| Map element | A focused image of the map, without surrounding page content. | page.locator('#map').screenshot() |
| Full scrollable page | A tall image of the document’s full scrollable height. | page.screenshot({ fullPage: true }) |
These choices control the image extent. They do not replace the work of triggering the page’s scroll-dependent behavior. Also, scrolling the document is not the same as panning or zooming a map: if you need a larger geographic area, interact with the map itself using its controls or supported API.
2. Install Playwright
For a Node.js project, install Playwright and its Chromium browser:
npm init -y
npm install playwright
npx playwright install chromium
Save the script below as capture-map.js, set TARGET_URL to the page, and run it with TARGET_URL="https://example.com/map" node capture-map.js. Replace the example map selector and marker readiness condition with ones from your application.
3. Scroll, wait for content, and capture
This example scrolls the document in increments, giving scroll observers and event handlers an opportunity to run. It waits for the first marker to become visible before capturing the map. If the markers are in a nested scroll container, use the alternative in the next section.
const { chromium } = require('playwright');
const url = process.env.TARGET_URL;
if (!url) throw new Error('Set TARGET_URL to the page to capture.');
(async () => {
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
try {
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 60000 });
// Replace #map and [data-testid="map-marker"] with site-specific selectors.
const map = page.locator('#map');
await map.waitFor({ state: 'visible', timeout: 15000 });
// Scroll the document in steps. This is a trigger, not a readiness check.
await page.evaluate(async () => {
const pause = ms => new Promise(resolve => setTimeout(resolve, ms));
const step = Math.max(200, Math.floor(window.innerHeight * 0.8));
const maxY = Math.max(0, document.documentElement.scrollHeight - window.innerHeight);
for (let y = 0; y <= maxY; y += step) {
window.scrollTo(0, y);
await pause(150);
}
window.scrollTo(0, maxY);
});
// Prefer an app-specific ready signal where possible. This locator is illustrative.
await page.locator('[data-testid="map-marker"]').first().waitFor({
state: 'visible',
timeout: 20000
});
await map.screenshot({ path: 'map.png', animations: 'disabled' });
// For the current viewport instead: await page.screenshot({ path: 'viewport.png' });
// For the whole document instead: await page.screenshot({ path: 'full-page.png', fullPage: true });
} finally {
await browser.close();
}
})();
The script uses domcontentloaded so it does not wait for every eager page resource before starting. A page’s load event is not a universal signal that lazy content has loaded: browsers can defer lazy images, frames, and other resources until needed. See MDN’s documentation on Intersection Observer and the load event.
4. Handle a nested scrolling container
Some pages keep the document still while a panel or map wrapper scrolls internally. In that case, scroll the actual container. An observer can use a specified ancestor as its root, so document scrolling may never intersect the targets it watches. This is an implementation inference from the observer model; confirm the page’s DOM and behavior.
const scroller = page.locator('.results-panel'); // Replace with the real scroll container.
await scroller.waitFor({ state: 'visible' });
await scroller.evaluate(async element => {
const pause = ms => new Promise(resolve => setTimeout(resolve, ms));
const step = Math.max(150, Math.floor(element.clientHeight * 0.75));
const maxTop = Math.max(0, element.scrollHeight - element.clientHeight);
for (let top = 0; top <= maxTop; top += step) {
element.scrollTop = top;
element.dispatchEvent(new Event('scroll', { bubbles: true }));
await pause(150);
}
element.scrollTop = maxTop;
element.dispatchEvent(new Event('scroll', { bubbles: true }));
});
await page.locator('[data-testid="map-marker"]').first().waitFor({ state: 'visible' });
await page.locator('#map').screenshot({ path: 'map.png' });
Setting scrollTop and dispatching an event works for many ordinary scroll containers. If the site’s component requires actual wheel input, use Playwright’s mouse wheel over the container instead:
const box = await scroller.boundingBox();
if (!box) throw new Error('Scroll container is not visible.');
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
for (let i = 0; i < 8; i++) {
await page.mouse.wheel(0, Math.floor(box.height * 0.7));
await page.waitForTimeout(150); // Replace with a readiness condition when available.
}
5. Wait for the right readiness signal
Scrolling triggers work; it does not prove the work finished. Prefer a condition tied to the application’s actual output or data flow:
- Marker rendered: wait for a marker locator to be visible, or for a known marker count to reach the expected minimum.
- Map ready: wait for an app-provided ready attribute, callback, or state exposed for automation.
- Request completed: wait for the specific marker or tile request to finish, if the endpoint is identifiable and stable.
- Known delay: use a short delay only when there is no better signal; it is a timing guess and can be flaky under load.
Example of waiting for a minimum marker count after scrolling:
await page.waitForFunction(() => {
return document.querySelectorAll('[data-testid="map-marker"]').length >= 10;
}, { timeout: 20000 });
For a canvas-based map, DOM marker selectors may not exist even when markers are drawn. Wait for the map’s own ready signal or the relevant data request, and use an element screenshot as a visual capture. A visual screenshot can also help guide later coordinate-based interaction when map controls are not exposed through accessible elements. Avoid relying on undocumented internal selectors if the application can provide a stable test hook.
6. Capture with Python Playwright
Python can use the same sequence: navigate, scroll in steps, wait for an application-specific signal, then capture. Install with pip install playwright and playwright install chromium.
import asyncio
import os
from playwright.async_api import async_playwright
async def main():
url = os.environ.get("TARGET_URL")
if not url:
raise RuntimeError("Set TARGET_URL to the page to capture.")
async with async_playwright() as p:
browser = await p.chromium.launch()
page = await browser.new_page(viewport={"width": 1440, "height": 1000})
try:
await page.goto(url, wait_until="domcontentloaded", timeout=60000)
map_element = page.locator("#map") # Replace for the target site.
await map_element.wait_for(state="visible", timeout=15000)
await page.evaluate("""async () => {
const pause = ms => new Promise(resolve => setTimeout(resolve, ms));
const step = Math.max(200, Math.floor(window.innerHeight * 0.8));
const maxY = Math.max(0, document.documentElement.scrollHeight - window.innerHeight);
for (let y = 0; y <= maxY; y += step) {
window.scrollTo(0, y);
await pause(150);
}
window.scrollTo(0, maxY);
}""")
# Illustrative selector; use the site's actual marker or ready condition.
await page.locator('[data-testid="map-marker"]').first.wait_for(
state="visible", timeout=20000
)
await map_element.screenshot(path="map.png", animations="disabled")
# Full document alternative: await page.screenshot(path="full-page.png", full_page=True)
finally:
await browser.close()
asyncio.run(main())
7. Configure the screenshot deliberately
- Viewport size: set it before navigation or before the page lays out the map. Responsive breakpoints can change map dimensions and marker clustering.
- Device scale factor: use a higher scale factor when you need a denser raster image; this increases output dimensions and memory use.
- Animation: disable animations for repeatable output when transitions are irrelevant. If an animation reveals markers, wait for its final state first.
- Output format: Playwright selects format from the path extension (or an explicit type option). Choose PNG for lossless output; JPEG or WebP may reduce file size where supported by the API and use case.
- Full page:
fullPage: truecaptures the full scrollable document. It can produce a very tall image and does not necessarily cause each section’s scroll handlers to run. - Element screenshot: a locator screenshot focuses on the element and may scroll it into view. If the site lazily loads based on a different container, explicitly drive that container first.
8. Troubleshoot missing markers and failed captures
| Symptom | Likely cause | Fix |
|---|---|---|
| No markers in the image | The screenshot ran before the marker fetch or render completed, or the selector is wrong. | Inspect the DOM and network activity; wait for a marker, request, or app-ready signal after scrolling. |
| Document scroll has no effect | The page uses a nested scroller, or marker loading is triggered by map pan/zoom. | Scroll the actual ancestor container; if the map loads by movement, interact with its controls or supported map API. |
| Wait times out for marker selector | Markers are rendered on canvas, use another selector, or require a different viewport/area. | Inspect the rendered page and accessible tree; use a map readiness signal or request condition for canvas maps. |
| Screenshot is clipped or unusually tall | The chosen capture scope does not match the goal, or the document has a long scroll height. | Capture the map locator for a focused image; use viewport capture for the visible state and full-page only for the document. |
| Markers are duplicated or clustered differently | Viewport dimensions, zoom, device scale, or map settling changed the layout. | Set a stable viewport and scale factor, then wait for clustering or map rendering to settle. |
| Navigation times out | The site keeps connections open, loads slowly, or has third-party resources that delay the chosen condition. | Use an appropriate navigation condition such as domcontentloaded, increase the navigation timeout if justified, and separately wait for the map signal. |
| Blank or partially painted map | Tiles or canvas rendering are still in progress, or the map failed to load data. | Wait for the specific tile/data readiness condition; inspect console and network errors before capturing. |
| Headless result differs from a local browser | Different viewport, device scale, fonts, permissions, geolocation, or browser state. | Set those inputs explicitly and use a consistent browser version and context. |
9. Performance, reliability, and cost
Scrolling the entire document, waiting at every step, and capturing a full-page image costs more time and memory than capturing one map element. Limit the scroll range to the area needed, use a step size large enough to make progress but small enough to trigger relevant observers, and avoid arbitrary long pauses. For dynamic marker sets, wait on a specific signal instead of repeatedly polling a broad part of the page.
For repeatable automation, fix the viewport, browser context, geolocation and permissions when relevant, and use stable test selectors. Give navigation and readiness waits separate timeouts so a slow page is distinguishable from a map that never becomes ready. Close the browser in a finally block and save diagnostic screenshots or logs on failure if this is part of a larger test pipeline. The target site controls its own network behavior, data, map limits, and rate limits; account for those independently.
Running Playwright locally has no per-screenshot API charge, but it requires browser installation and compute, and your run time grows with the scroll and wait strategy. If you are processing many pages, include infrastructure, retries, and storage in your cost estimate. ScreenshotNeo’s stated plans are 1,000 shots/month free without a card, then Starter $5 for 3,000, Growth $15 for 15,000, Pro $39 for 60,000, Scale $99 for 250,000, and Business $249 for 1,000,000; yearly billing gives two months free. Every feature is on every plan.
Or skip the browser setup
ScreenshotNeo is a website screenshot API and MCP server. A single request captures a URL. For example, this cURL request saves a WebP screenshot:
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
See the ScreenshotNeo API documentation for request options. Equivalent Python and Node.js calls:
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 import('node:fs/promises').then(fs => fs.writeFile('shot.webp', Buffer.from(await res.arrayBuffer())));
ScreenshotNeo removes cookie banners, popups, and chat widgets before capture. 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, and paid plans start at $5 for 3,000. Sign up for 1,000 free screenshots a month, with no card required.
FAQ
Does fullPage: true scroll the page to load every marker?
It selects the full scrollable document for the screenshot. For scroll-dependent behavior, explicitly scroll the document or the relevant nested container, then wait for the content.
Why are there no marker elements to wait for?
The map may draw markers into a canvas, or the visible map may not use DOM elements for them. Use the map’s own ready state or data request as the wait condition.
Should I wait for networkidle?
Only if the page’s network activity becomes idle predictably. Maps can keep connections or make background requests, so a marker or application-specific ready signal is often more precise.
Can I use a screenshot to interact with a map?
A screenshot can serve as a visual reference. For interaction, prefer accessible controls and locators; canvas-only controls may require coordinates, which can be sensitive to viewport and layout changes.


