ScreenshotNeo

BlogHow-to

How to Work with Frames and Iframes in Puppeteer

Find the right Puppeteer frame, wait for it to be ready, and interact safely across nested iframes and navigation.

By the ScreenshotNeo team4 October 20268 min read

Direct answer: Puppeteer represents each document frame with a Frame. Use page.mainFrame() for the top-level document, page.frames() to enumerate attached frames, or page.waitForFrame() to wait for a matching iframe. Then run selectors, locators, and evaluation on that frame itself. Page-level selectors search the main frame; they do not automatically search iframe documents. Puppeteer Frame reference · Page reference.

This guide uses the public Puppeteer API. The official references cited here carry version labels from 25.9.0 through 25.12.0; check the reference matching your installed version before copying methods into an older project.

1. Install Puppeteer and open a page

The following CommonJS example launches Puppeteer, navigates to a page, and prints the main frame and attached frame URLs. Install Puppeteer in your project with npm install puppeteer; the package downloads a compatible Chrome for Testing by default. If your environment supplies its own browser, configure the executable path as appropriate for that installation.

const puppeteer = require('puppeteer');

(async () => {
  const browser = await puppeteer.launch({ headless: true });
  try {
    const page = await browser.newPage();
    await page.goto('https://example.com', { waitUntil: 'domcontentloaded' });

    console.log('Main frame:', page.mainFrame().url());
    console.log('All attached frames:');
    for (const frame of page.frames()) {
      console.log(frame.url());
    }
  } finally {
    await browser.close();
  }
})();

The Frame is a document context. A page can have a main frame and child frames, and child frames can themselves contain more frames. Use the tree APIs when nesting matters rather than assuming every iframe is a direct child of the main document.

2. Inspect the frame tree

Each Frame exposes its child frames. Recursing from the main frame preserves the parent-child structure and is useful when a target is nested.

function dumpFrameTree(frame, indent = '') {
  console.log(`${indent}${frame.url()}`);
  for (const child of frame.childFrames()) {
    dumpFrameTree(child, `${indent}  `);
  }
}

dumpFrameTree(page.mainFrame());

You can also inspect the flat list with page.frames(). Frame order is not a reliable identity: frames may attach, navigate, or detach as the page loads and rerenders. Match a stable URL or an embedding element property instead. Puppeteer documents the frame lifecycle on the Frame class reference.

3. Wait for and identify the intended iframe

If the iframe is created asynchronously, use page.waitForFrame(). Its predicate receives a frame and can inspect the iframe element through frame.frameElement(). This example matches the embedding element’s name attribute:

const frame = await page.waitForFrame(async candidate => {
  const element = await candidate.frameElement();
  if (!element) return false;
  return element.evaluate(el => el.getAttribute('name') === 'checkout');
}, { timeout: 15000 });

console.log('Matched frame:', frame.url());

Choose a property that is stable and sufficiently specific. If the name may be reused, combine it with a URL condition or another iframe attribute. A URL-only match is concise when the embedded URL is known:

const frame = await page.waitForFrame(candidate =>
  candidate.url().startsWith('https://payments.example/checkout')
);

The available predicate and options are documented in Page.waitForFrame(). Do not assume a frame URL will remain fixed through redirects or application navigation; if that can happen, match identity using a stable embedding attribute and then wait for the frame’s expected content.

4. Query and interact inside the frame

Once you have the right Frame, scope all work to it. For user-like interactions, Frame.locator() creates a locator in that frame’s document:

const submit = frame.locator('button[type="submit"]');
await submit.click();

Locators can use CSS and Puppeteer-specific selector syntax, and are designed to find elements and retry actions when their preconditions are not yet met. See Frame.locator() and the Locator reference.

For lower-level selector access, Frame.$() returns the first matching element handle or null, and Frame.$eval() runs a function with the first matching element. Frame.evaluate() executes in the frame’s document context:

const heading = await frame.$eval('h1', el => el.textContent.trim());
const title = await frame.evaluate(() => document.title);
console.log({ heading, title });

const optional = await frame.$('.optional');
if (!optional) {
  console.log('The optional element is not in this frame');
}

Evaluation does not cross into child frames. If the target is in a nested iframe, find that child frame and query it separately. Likewise, a selector issued against page searches the main frame and will not find an element that exists only inside an iframe.

5. Wait for content or frame navigation

Use the wait that represents the condition you actually need. If a particular element must appear, use Frame.waitForSelector(). It is documented to work across navigations in that frame:

const confirmation = await frame.waitForSelector('[data-state="ready"]', {
  timeout: 10000,
});
if (!confirmation) {
  throw new Error('Ready state was not found');
}

For an action expected to navigate the frame, attach the navigation wait before triggering the action and await both:

const [response] = await Promise.all([
  frame.waitForNavigation({ waitUntil: 'domcontentloaded', timeout: 15000 }),
  frame.click('a.continue'),
]);

console.log('Navigation response:', response?.status() ?? 'no response');

This ordering avoids missing a fast navigation. History API URL changes count as navigation according to Frame.waitForNavigation(). The return value is the main resource response and can be null, including for navigation to about:blank or a same-URL hash change. See Frame.waitForSelector() for selector wait options and behavior.

Prefer a meaningful selector or navigation condition over an arbitrary sleep. A load event alone may also be too early for a single-page app whose relevant content appears after client-side work; wait for the specific state your next action depends on.

6. Handle nested frames and frame replacement

To interact with a nested iframe, locate it as a child of its parent frame, then use the resulting child frame as the document context:

const outer = await page.waitForFrame(async candidate => {
  const el = await candidate.frameElement();
  return el ? el.evaluate(node => node.id === 'outer-frame') : false;
});

const inner = await page.waitForFrame(candidate =>
  candidate.parentFrame() === outer && candidate.url().includes('/inner')
);

await inner.waitForSelector('button.confirm');
await inner.locator('button.confirm').click();

Frame lifecycle changes include attachment, navigation, and detachment. If an application removes and recreates an iframe, a previously saved Frame reference may be detached. Check frame.detached when diagnosing stale references, then reacquire the target from the current page frame tree or wait for it again.

7. cURL, Python, and Node.js references

Frame inspection and interaction are Puppeteer operations, so cURL and Python do not provide equivalent frame APIs. They can call a remote browser service that exposes such operations, but that service would define its own interface. Puppeteer itself runs in Node.js; the complete runnable examples above use it directly.

For a screenshot of a public page without managing a browser or frame lifecycle, ScreenshotNeo provides a website screenshot API and MCP server. It captures a supplied URL as an image or PDF; it does not expose Puppeteer frame selection and interaction APIs.

Or skip the browser setup

For a screenshot of a page, make one request to ScreenshotNeo. See the ScreenshotNeo API documentation for options.

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
import requests

r = requests.get(
    "https://api.screenshotneo.com/v1/shot",
    params={"access_key": "YOUR_API_KEY", "url": "https://stripe.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://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`Screenshot request failed: ${res.status}`);
const fs = require('node:fs/promises');
await fs.writeFile('shot.webp', Buffer.from(await res.arrayBuffer()));
  • Cookie banners, newsletter popups, and chat widgets are removed before the shot; each cleanup step can be turned off.
  • Bot checks and CAPTCHAs, blank pages, timeouts, failed loads, and cache hits are never billed. Response headers report the page verdict and billing status.
  • An MCP server lets AI agents use take_screenshot, get_page_info, and capture_pdf.
  • The free plan includes 1,000 screenshots a month with no card. Paid plans start at $5 for 3,000 screenshots; every feature is on every plan.

Sign up free for 1,000 screenshots a month, with no card.

8. Troubleshooting

Symptom Likely cause Fix
page.$() returns null, but the element is visible The element is inside an iframe, while the page selector searches the main frame. Find the matching Frame, then use frame.$(), frame.locator(), or another frame-scoped API.
waitForFrame() times out The iframe was never attached, its URL or attribute differs from the predicate, or it appeared after an earlier assumption about page timing. Inspect page.frames() and the recursive frame tree. Relax or correct the match and set a timeout appropriate to the page.
The matched frame is wrong The predicate matches a non-unique name, URL prefix, or another frame. Combine stable identity signals, such as parent frame plus URL and an embedding element attribute.
A selector wait times out in a frame The selector is wrong, the content has not reached the expected state, or the target lives in a nested child frame. Inspect that frame’s URL and content, confirm the selector, and locate the child frame if needed. Wait for a meaningful readiness condition.
Click succeeds but expected content is missing The action may have navigated the frame, rerendered it, or initiated app work without a document navigation. For navigation, pair waitForNavigation() with the action. For an in-place update, wait for the resulting selector or state instead.
“Frame detached” or stale frame errors The application removed or replaced the iframe while code retained its old reference. Re-read the frame tree and reacquire the matching frame after replacement.
Navigation wait returns null The navigation had no main resource response, such as about:blank or a same-URL hash change. Check the resulting frame URL or wait for the content condition instead of requiring a response object.

9. Performance, reliability, and cost

Frame inspection is inexpensive compared with browser startup and page loading, but every wait adds latency up to its timeout. Avoid polling with repeated arbitrary sleeps; wait for the specific frame, selector, or navigation once. For workflows with repeated interactions, keep the browser and page alive for the workflow and close them in a finally block so errors do not leave browser processes behind.

Reliability depends on choosing stable frame identity and an appropriate readiness condition. URLs can redirect or change, iframe attributes can be mutable, and frames can detach during rerenders. Combine identity signals where needed, use navigation waits only when a document or URL change is expected, and reacquire detached frames.

Puppeteer is open-source software; running it has no per-screenshot fee from the library itself. Budget for the machine or browser infrastructure, runtime, network use, and any external services your page workflow depends on. ScreenshotNeo’s stated pricing is 1,000 shots per month free, 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. Only clean shots are billed; consult the docs for API behavior and options.

10. FAQ

Does Puppeteer automatically search inside every iframe?

No. Page-level selectors operate on the main frame. Identify the iframe and use its Frame methods.

Can I use a main-frame element handle inside an iframe?

No. Each frame has its own document context. Query within the frame that owns the element.

Should I wait for navigation or for a selector?

Wait for navigation when the action should change the frame document or URL. Wait for a selector when the required outcome is an element appearing, including after an in-place app update.

Why can the same iframe have a different frame reference later?

A rerender can detach and recreate the iframe. Reacquire the current frame after such a lifecycle change.