How to screenshot a lazy-loaded webpage with Selenium
Scroll to trigger lazy loading, wait for the content you need, then capture it with Selenium. Includes Python, JavaScript, and troubleshooting.
To screenshot a lazy-loaded webpage with Selenium, scroll the page or the relevant inner scroll container to trigger loading, wait for the content you need to appear or stabilize, then capture the screenshot. Selenium’s navigation completion alone does not mean JavaScript-driven content is ready. The right scroll target and completion condition depend on the site.
This guide uses Python for the main example and includes a JavaScript equivalent. Both examples scroll in steps, wait for page-specific content, and save a PNG. Choose a selector and stopping condition that match the page you are capturing.
Why Selenium screenshots miss lazy-loaded content
Many pages defer images or other content until they approach the viewport. A screenshot taken right after navigation may capture placeholders or blank areas because navigation waits concern document readiness, not necessarily completion of later application activity. Selenium explicitly cautions that ready state does not always mean a page has finished loading, especially for JavaScript applications. See Selenium’s browser options documentation and waiting strategies.
Scrolling can prompt the page to load deferred content, but first determine whether the browser window or a nested element actually scrolls. Then wait for an observable result: a target element appears, the item count increases, an image completes loading, or a loading indicator disappears.
Python: scroll, wait, and capture
Install Selenium with python -m pip install selenium. This example uses Selenium Manager, which can manage the browser driver for supported setups. It assumes the target page exposes elements matching .product-card; replace that selector and the target URL with values from your page.
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
URL = "https://example.com/catalog"
ITEM_SELECTOR = ".product-card" # Replace with a selector on the target page.
MAX_STEPS = 40
options = webdriver.ChromeOptions()
options.add_argument("--window-size=1440,1000")
# Uncomment for a headless run:
# options.add_argument("--headless=new")
driver = webdriver.Chrome(options=options)
wait = WebDriverWait(driver, 15)
try:
driver.get(URL)
wait.until(EC.presence_of_element_located((By.TAG_NAME, "body")))
previous_count = 0
unchanged_steps = 0
for _ in range(MAX_STEPS):
items = driver.find_elements(By.CSS_SELECTOR, ITEM_SELECTOR)
current_count = len(items)
# Example target condition: capture after at least 20 cards appear.
# Change this to the content or boundary you actually need.
if current_count >= 20:
break
if current_count == previous_count:
unchanged_steps += 1
else:
unchanged_steps = 0
previous_count = current_count
# Scroll the window by about one viewport to trigger nearby content.
driver.execute_script(
"window.scrollBy(0, Math.max(window.innerHeight * 0.8, 400));"
)
# Wait briefly for a count increase. A timeout can mean the page is
# at its end, still loading slowly, or uses a different loading signal.
try:
wait.until(
lambda d: len(d.find_elements(By.CSS_SELECTOR, ITEM_SELECTOR))
> current_count
)
except Exception:
pass
if unchanged_steps >= 3:
break
# Optional: return to the top if the desired output is a viewport shot
# of the page's first screen. Remove this if you want the current position.
driver.execute_script("window.scrollTo(0, 0)")
# Wait for a page-specific target before capturing.
wait.until(EC.presence_of_element_located((By.CSS_SELECTOR, ITEM_SELECTOR)))
driver.save_screenshot("lazy-page.png")
finally:
driver.quit()
The example stops when it sees 20 matching items or the count stays unchanged across several steps. Adjust the target count and maximum steps to suit the page. If a fixed target item matters, wait for that specific selector instead. The broad exception catch around the count wait keeps the loop moving after a no-change timeout; for production code, catch TimeoutException specifically and log the condition so genuine failures are visible.
Wait for images to finish loading
If the elements exist before their image files have loaded, wait for the relevant images’ complete and naturalWidth properties. This checks browser image state for the selected elements; it cannot prove that every visual effect or third-party widget has settled.
images_ready = wait.until(lambda d: d.execute_script("""
const images = [...document.querySelectorAll('.product-card img')];
return images.length > 0 && images.every(img => img.complete && img.naturalWidth > 0);
""",))
if not images_ready:
raise RuntimeError("Product images did not all load")
driver.save_screenshot("lazy-page.png")
Replace .product-card img with the image selector. If the page uses CSS background images, canvases, or an application-specific loading state, use a condition suited to that implementation.
JavaScript: the same workflow with Selenium WebDriver
Install the JavaScript package with npm install selenium-webdriver and configure a supported browser and driver on your system. This CommonJS example uses the same item-count approach. Change the selector, URL, and target count for the site.
const { Builder, By, until } = require('selenium-webdriver');
(async () => {
const driver = await new Builder().forBrowser('chrome').build();
const url = 'https://example.com/catalog';
const selector = '.product-card'; // Replace for the target page.
const maxSteps = 40;
try {
await driver.manage().window().setRect({ width: 1440, height: 1000 });
await driver.get(url);
await driver.wait(until.elementLocated(By.css('body')), 15000);
let unchangedSteps = 0;
for (let step = 0; step < maxSteps; step += 1) {
const items = await driver.findElements(By.css(selector));
const count = items.length;
if (count >= 20) break; // Use the amount of content you need.
await driver.executeScript(
'window.scrollBy(0, Math.max(window.innerHeight * 0.8, 400));'
);
try {
await driver.wait(async d => {
const current = await d.findElements(By.css(selector));
return current.length > count;
}, 5000);
unchangedSteps = 0;
} catch (error) {
unchangedSteps += 1;
}
if (unchangedSteps >= 3) break;
}
await driver.wait(until.elementLocated(By.css(selector)), 15000);
await driver.executeScript('window.scrollTo(0, 0)');
const image = await driver.takeScreenshot();
require('node:fs').writeFileSync('lazy-page.png', image, 'base64');
} finally {
await driver.quit();
}
})().catch(error => {
console.error(error);
process.exitCode = 1;
});
In JavaScript, an asynchronous script executed through WebDriver must signal completion through its callback. The examples above use synchronous script execution for scrolling and DOM checks, while WebDriver’s own waits poll for the conditions. See Selenium’s JavaScript WebDriver API.
Handle a nested scroll container
Some feeds or galleries keep their own scrollbar while the document stays still. Scrolling window in that case will not bring the deferred content into view. Find the element that owns the scroll, then scroll it in steps.
SCROLLER_SELECTOR = ".results-pane" # Replace with the actual scroll container.
for _ in range(40):
scroller = wait.until(
EC.presence_of_element_located((By.CSS_SELECTOR, SCROLLER_SELECTOR))
)
before = len(driver.find_elements(By.CSS_SELECTOR, ITEM_SELECTOR))
driver.execute_script(
"arguments[0].scrollTop += Math.max(arguments[0].clientHeight * 0.8, 300);",
scroller,
)
try:
wait.until(
lambda d: len(d.find_elements(By.CSS_SELECTOR, ITEM_SELECTOR)) > before
)
break # New content arrived; continue the loop if more is needed.
except Exception:
# Inspect the container's scroll position and page-specific end signal.
break
For a robust loop, verify whether the container reached its end using its scrollTop, clientHeight, and scrollHeight. A site may also virtualize content, replacing old DOM elements as you scroll; in that case, a total item count may stay constant, so wait for the specific target item or another page signal instead.
Choose the screenshot scope
| Capture | Use it when | What to check |
|---|---|---|
| Current page or viewport | You need the visible browser context after scrolling or after returning to the top. | save_screenshot or the binding’s page screenshot captures the current browsing context according to driver behavior. |
| Element screenshot | You need one chart, card, or other element. | Element screenshots are bounded by the element’s bounding rectangle; do not assume they capture all of a long element beyond the visible region. |
| Full page | You need content across the document, including sections below the fold. | Full-page support and behavior depend on the Selenium binding and driver. Confirm support and inspect the saved image for clipping. |
Selenium’s element screenshot API documents the element’s bounding rectangle as the capture scope. Full-page screenshot methods are not universally supported across binding and driver combinations; for example, Selenium’s Ruby API makes support conditional. See the Ruby TakesScreenshot API and the JavaScript WebElement API.
Configuration and edge cases
- Pick a stable selector. Prefer a content-specific selector over fragile positional selectors. Confirm it identifies the intended items on the page.
- Wait for the right signal. For a known target, wait for its presence or visibility. For an infinite feed, watch item changes or a page-specific end marker. A fixed sleep may be useful as a small settling delay, but is less reliable as the sole condition.
- Account for sticky headers. Scrolling may leave a fixed header over the content. Capture at a deliberate position, or hide the header with page-specific CSS if that is acceptable for your use case.
- Watch for virtualized lists. Some interfaces remove off-screen nodes. Scrolling to the bottom may not leave all content in the DOM at once; capture sections as they appear or use the site’s export mechanism if one exists.
- Allow for delayed image decoding. An image can be downloaded but not yet visually settled. Checking
completeandnaturalWidthhelps with ordinary image elements; inspect the output for remaining layout shifts. - Use a practical boundary. Infinite scroll may never finish. Stop at a target record, a known page boundary, or after repeated no-change checks, with a maximum number of steps.
- Set viewport dimensions deliberately. Viewport size changes responsive layout and how many items enter view per scroll. Use a consistent window size when comparing captures.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Screenshot is blank or mostly a skeleton | The screenshot ran after document readiness but before application content arrived, or the page failed to load. | Wait for a page-specific content condition; inspect the page and browser logs if the condition never appears. |
| Only content near the top loads | The page was never scrolled far enough, or an inner container owns scrolling. | Scroll incrementally and identify the actual scrolling element. |
| Wait times out for a selector | The selector is wrong, the target is not reached, or a consent gate or error state prevents content from appearing. | Inspect the live DOM, confirm the selector, and wait for the page’s actual loading or error state. |
| Loop stops although more content exists | Network delay exceeded the wait; the list is virtualized; or the loop watches a count that does not change. | Use a longer bounded wait and a target-specific condition, or detect changes to the intended content instead of total count. |
| Images show as empty boxes | Image requests are still pending or failed, or the site uses backgrounds or another rendering method. | Wait for selected image elements to complete, check their natural width, and adapt the condition for the site’s rendering method. |
| Screenshot cuts off the page | The chosen API captures the viewport or element bounds, not a full document. | Check your binding’s full-page support. Otherwise capture after scrolling in sections or use a tool that supports full-page capture. |
| Stale element reference during scrolling | The page replaced nodes while loading or virtualizing. | Re-find elements on each loop iteration instead of retaining element references across updates. |
| Driver or browser startup fails | Browser and driver setup is unavailable, incompatible, or blocked in the runtime. | Install a supported browser, use a compatible driver setup, and consult the Selenium installation and driver documentation for your language. |
Performance, reliability, and cost
Incremental scrolling with explicit waits usually avoids spending the same fixed delay at every step, but the total capture time still depends on the page, network, browser, and wait conditions. Keep a maximum scroll count and per-condition timeout so a never-ending feed cannot hold a job indefinitely. Record the final item count or target reached, and treat an unmet target as an incomplete capture rather than silently saving an image.
For repeatable output, fix the viewport and browser configuration, wait for the same page-specific condition, and inspect captures for missing or clipped content. Selenium automation runs in your own browser environment; compute, browser setup, and any hosted runner costs depend on your infrastructure. The sources do not provide universal timing or cost figures.
Or skip the browser setup
ScreenshotNeo is a website screenshot API and MCP server for developers. One GET request can return a PNG, JPEG, WebP, or PDF. Its full-page capture loads lazy images. Cookie banners are accepted before capture, and 60+ known consent platforms, newsletter popups, and chat widgets are removed; each step can be turned off. Bot checks, blank pages, timeouts, failed loads, and cache hits cost nothing, and response headers report the page verdict and billing status.
For AI workflows, its 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 a month with no card; paid plans start at $5 for 3,000 shots. Every feature is on every plan. See the ScreenshotNeo API documentation for parameters and configuration.
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
Sign up free for 1,000 screenshots a month, with no card required.
FAQ
Does Selenium wait for lazy-loaded images automatically?
No. Navigation readiness does not guarantee that deferred images or later JavaScript activity have finished. Scroll and wait for a condition that represents the content you need.
Should I scroll all the way to the bottom?
Only if the desired content is there and the page has a meaningful end. Infinite feeds need a target or a bounded stopping rule.
Will an element screenshot capture a long element in full?
Do not assume so. The documented element screenshot scope is the element’s bounding rectangle, and full-page behavior depends on the binding and driver.
Can a fixed delay solve missing content?
It can give a page more time, but it does not confirm the target content arrived. Prefer an explicit wait tied to the page’s content or loading state.


