How to Screenshot a Lazy-Loaded Iframe on a Website
Bring a lazy iframe into view, wait for its content to be ready, then capture the frame or page with Playwright or Puppeteer.
To screenshot a lazy-loaded iframe, make it enter the viewport, wait until its embedded content is actually ready, and only then capture the iframe or the page. A full-page screenshot does not guarantee that a browser will trigger every offscreen frame or wait for its content to finish rendering.
This guide shows complete Playwright and Puppeteer examples in JavaScript, explains cross-origin limits and common failure modes, and covers when a screenshot API can simplify the job.
1. Why the iframe is blank in a screenshot
An iframe with loading="lazy" can defer navigation while it is far below the viewport. This avoids loading embedded content a visitor may never see. The browser may start loading it as it approaches the viewport; the exact timing is browser-controlled. web.dev’s iframe lazy-loading guide describes the behavior and the lazy and eager values.
A screenshot call captures the current rendered state. It does not prove the iframe has navigated, that its application has finished rendering, or that a third-party embed is permitted to load. Google’s guidance for lazy-loaded content is to make relevant content load when it is visible in the viewport. Google Search Central’s lazy-loading guidance
The reliable sequence is:
- Find the intended iframe.
- Bring its element into view.
- Wait for a condition tied to the actual embedded content.
- Capture the iframe element, a target inside its document when accessible, or the whole page.
2. Playwright: scroll, wait, and capture
Install Playwright and a browser using the commands below. Set PAGE_URL to the page being captured, IFRAME_SELECTOR to a selector that uniquely identifies the iframe, and READY_TEXT to text that appears inside its document when ready. Use a real, stable readiness signal for the site; the example text is a placeholder.
npm install playwright
npx playwright install chromium
Save this as screenshot-iframe.mjs and run it with PAGE_URL=https://example.com IFRAME_SELECTOR='iframe[data-testid="embed"]' READY_TEXT='Expected embed content' node screenshot-iframe.mjs.
import { chromium } from 'playwright';
const pageUrl = process.env.PAGE_URL;
const iframeSelector = process.env.IFRAME_SELECTOR;
const readyText = process.env.READY_TEXT;
if (!pageUrl || !iframeSelector || !readyText) {
throw new Error('Set PAGE_URL, IFRAME_SELECTOR, and READY_TEXT');
}
const browser = await chromium.launch({ headless: true });
try {
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
await page.goto(pageUrl, { waitUntil: 'domcontentloaded', timeout: 60000 });
const iframe = page.locator(iframeSelector);
await iframe.waitFor({ state: 'attached', timeout: 15000 });
await iframe.scrollIntoViewIfNeeded();
// frameLocator waits for the frame content. This text must be specific
// to the embedded document and visible only when its useful content is ready.
const embeddedReady = page.frameLocator(iframeSelector).getByText(readyText, { exact: false });
await embeddedReady.waitFor({ state: 'visible', timeout: 45000 });
// Capture just the iframe's rendered box. Use page.screenshot below for the page.
await iframe.screenshot({ path: 'iframe.png', animations: 'disabled', timeout: 30000 });
// await page.screenshot({ path: 'page.png', fullPage: true, animations: 'disabled' });
} finally {
await browser.close();
}
The script intentionally waits for domcontentloaded rather than requiring every page resource to finish before scrolling. Its meaningful wait is for the chosen embedded text. For an embed without stable text, replace that condition with a known child selector, expected frame URL, or application-specific ready signal. Playwright documents page navigation and screenshot APIs and screenshot workflows.
Capture the full page or a child element
Once the content is ready, choose the output that matches the goal:
await iframe.screenshot({ path: 'iframe.png' })captures the visible iframe box.await page.screenshot({ path: 'page.png' })captures the current viewport.await page.screenshot({ path: 'page.png', fullPage: true })captures the page’s full scrollable area, but should not replace the visibility and readiness steps.- For a same-origin frame, use
page.frameLocator(iframeSelector).locator('css=...')to target a child element, then call its screenshot method.
3. Puppeteer: bring the frame into view before capture
Install Puppeteer and save the following as screenshot-iframe.cjs. Set the three environment variables as in the Playwright example, then run node screenshot-iframe.cjs.
npm install puppeteer
const puppeteer = require('puppeteer');
(async () => {
const pageUrl = process.env.PAGE_URL;
const iframeSelector = process.env.IFRAME_SELECTOR;
const readyText = process.env.READY_TEXT;
if (!pageUrl || !iframeSelector || !readyText) {
throw new Error('Set PAGE_URL, IFRAME_SELECTOR, and READY_TEXT');
}
const browser = await puppeteer.launch({ headless: true });
try {
const page = await browser.newPage();
await page.setViewport({ width: 1440, height: 1000 });
await page.goto(pageUrl, { waitUntil: 'domcontentloaded', timeout: 60000 });
const iframeElement = await page.waitForSelector(iframeSelector, { timeout: 15000 });
await iframeElement.evaluate(element => element.scrollIntoView({ block: 'center' }));
const frame = await iframeElement.contentFrame();
if (!frame) throw new Error('Iframe element is present but its document is not available');
await frame.waitForFunction(
text => document.body && document.body.innerText.includes(text),
{ timeout: 45000 },
readyText
);
await iframeElement.screenshot({ path: 'iframe.png', timeout: 30000 });
// For a full-page capture instead: await page.screenshot({ path: 'page.png', fullPage: true });
} finally {
await browser.close();
}
})().catch(error => {
console.error(error);
process.exitCode = 1;
});
Puppeteer’s screenshot guide documents page and element screenshots. Its element screenshot flow attempts to scroll a hidden element into view, but explicitly scrolling first makes the lazy-load trigger clear and lets you wait for the frame before capture.
4. Choose a readiness signal that matches the embed
There is no universal event meaning “every iframe is visually complete.” A frame’s load event can indicate navigation completion while a single-page app inside it is still fetching or painting the content you need. Prefer a condition observable in the target frame:
| Readiness signal | Use it when | Limitation |
|---|---|---|
| Expected text or child selector | The embed shows stable, visible content when ready | Text or markup may change between versions |
| Known frame URL | The site redirects the iframe to a predictable destination | Navigation may finish before app content renders |
| Application-specific ready state | You control the embedded app or it exposes a documented signal | Third-party frames may not expose one |
| Network idle | The page has a finite network phase and no persistent requests | Analytics, streaming, or polling can prevent idle; idle alone does not prove visual correctness |
| Fixed delay | As a last resort for a site with no better observable signal | Slow runs can still be captured too early; fast runs waste time |
For a cross-origin frame, Playwright and Puppeteer automation can generally address the frame through browser automation APIs, but JavaScript executing in the parent page cannot freely inspect the child DOM. Use the automation library’s frame APIs and a selector or signal available to that frame. If the browser cannot access the frame because the embed is blocked or sandboxed, automation does not remove that site restriction.
5. Cross-origin limits and frame inspection
The browser’s same-origin policy limits a parent page’s JavaScript access to a frame from another origin. In particular, MDN documents that contentDocument returns the child document only when the parent and iframe are same-origin; otherwise it returns null. MDN: contentDocument and MDN: iframe element.
This restriction does not mean the frame cannot be rendered in a screenshot. It means you cannot rely on parent-page JavaScript to read its internals. Use frame-aware automation when allowed, or wait on observable parent-page evidence such as a changed frame URL or an app-provided signal. Do not try to bypass an embedding policy, authentication boundary, or sandbox restriction.
6. Configure the capture for the right result
- Viewport: Choose dimensions that give the iframe enough room and match the layout you need to document. Responsive embeds can render different content at different widths.
- Element versus page: Capture the iframe element to avoid unrelated page content; capture the viewport or full page when context matters.
- Lazy content inside the frame: The embedded document may itself lazy-load images or nested frames. If needed, scroll within that frame and wait for those targets too.
- Animations: Disable animations for a more stable capture where supported, or wait for the specific transition to finish.
- Timeouts: Give navigation and embed readiness separate, realistic limits. A generous timeout does not fix a frame that is blocked or never reaches the expected state.
- Output: PNG is useful for crisp UI details; choose JPEG or another format only if your capture pipeline and quality requirements call for it.
7. Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Iframe selector times out | The selector is wrong, the frame is injected later, or the page variant differs | Inspect the rendered page, wait for the iframe’s actual insertion condition, and make the selector unique. |
| Iframe element exists but stays blank | It has not approached the viewport, its navigation is pending, or the embed failed | Scroll it into view, inspect its src and frame URL, then wait for a frame-specific ready signal. |
contentFrame() returns null |
The iframe document is not attached or navigated yet | Wait for the iframe element and its navigation; retry obtaining the frame after the navigation condition. |
| Parent script cannot find child content | The iframe is cross-origin and same-origin restrictions apply | Use automation frame APIs or an app-provided signal. Do not inspect cross-origin content through parent-page JavaScript. |
| Text wait times out even though the frame appears | The expected text is absent, changed, hidden, or located in a nested frame | Choose a current visible marker and target the correct frame or nested frame. |
| Frame loads in a browser but not in automation | Embedding rules, authentication, cookies, geography, or bot checks differ in the automated context | Check the browser console and network behavior, configure authorized state where appropriate, and use a permitted capture route. |
| Screenshot is clipped or contains only a small box | The element capture includes only the iframe’s rendered dimensions | Adjust the viewport or capture the page; confirm the iframe has the expected CSS width and height. |
| Full-page screenshot still shows a blank frame | Full-page capture did not trigger or await the deferred navigation | Scroll the target into view, wait for its content, and then take the full-page screenshot. |
8. Performance, reliability, and cost
Loading an embed can be expensive: third-party frames may fetch scripts, media, and other resources. Lazy loading exists in part to avoid this cost until the content is near the viewport. For a one-off screenshot, load only the frame you need and avoid waiting for unrelated page activity. For batches, reuse a browser where your automation design permits it, limit concurrency to what your machine and target site can handle, and close pages and browsers reliably.
Reliability comes from explicit readiness checks and useful failure details. Log the page URL, iframe selector, frame URL when available, wait that timed out, and whether the iframe element was attached. Separate navigation timeout from content-readiness timeout so failures are diagnosable. A fixed sleep may be useful as a bounded fallback, but it is less reliable than waiting for the expected state.
Self-hosted browser automation has no per-screenshot API charge, but uses compute, browser dependencies, maintenance, and network resources. A hosted screenshot service trades browser setup for a per-plan allowance; confirm the service can meet the page’s iframe timing and access requirements before using it for a workflow.
9. Or skip the browser setup
ScreenshotNeo is a website screenshot API and MCP server. A single request can return a screenshot; for an iframe that loads on its own during the page visit, try:
See the ScreenshotNeo API documentation for request details.
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,
)
r.raise_for_status()
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}`);
await Bun.write('shot.webp', new Uint8Array(await res.arrayBuffer()));
ScreenshotNeo removes cookie and consent banners, newsletter popups, and chat widgets before capture; each cleanup step can be turned off. Bot checks, blank pages, failed loads, timeouts, and cache hits are not billed, and the response identifies the page verdict and billing status in headers. Its MCP server gives AI agents tools to take screenshots, inspect page information, and capture PDFs. The free plan includes 1,000 screenshots a month with no card; paid plans start at $5 for 3,000 screenshots. For a cross-origin or restricted frame, a screenshot API cannot guarantee access or readiness: check that the target page renders the iframe correctly in the service’s browser context. Sign up for 1,000 free screenshots a month, with no card.
10. Frequently asked questions
Does a full-page screenshot automatically load every lazy iframe?
No. It captures the page’s rendered state, and should not be treated as proof that offscreen frames were triggered or finished rendering.
Can I screenshot a cross-origin iframe?
You can capture rendered pixels when the browser is allowed to display the frame, but parent-page JavaScript cannot freely inspect a cross-origin child document. Use permitted browser automation frame APIs and observable readiness signals.
Should I change loading="lazy" to loading="eager"?
If you control the page and the iframe should load immediately, eager loading may be appropriate. For a page you do not control, trigger visibility during capture instead of expecting to change its markup.
Is a fixed wait enough?
It can work for a bounded fallback, but it can also be too short on slow runs and unnecessarily long on fast ones. Prefer a condition that confirms the content you need is present.


