ScreenshotNeo

BlogHow-to

Puppeteer Frame Events: How to Listen for Frame Changes

Listen for iframe attachment, navigation, and removal in Puppeteer. See runnable event, wait, and snapshot patterns, plus debugging tips.

By the ScreenshotNeo team4 October 20267 min read

To listen for iframe and nested frame changes in Puppeteer, register frameattached, framenavigated, and framedetached listeners on the parent Page. Each callback receives the affected Frame. Register listeners before the action or navigation you want to observe.

page.on('frameattached', frame => {
  console.log('Frame attached:', frame.url());
});

page.on('framenavigated', frame => {
  console.log('Frame navigated:', frame.url());
});

page.on('framedetached', frame => {
  console.log('Frame detached:', frame.url());
});

These are distinct lifecycle events: attachment adds a frame, navigation changes the URL of a frame, and detachment removes a frame. Puppeteer dispatches these lifecycle events on the parent page, including for child frames. See the official PageEvent reference and Frame API.

How do I listen for frame changes in Puppeteer?

Use the page’s event emitter for an ongoing stream of changes. The following complete Node.js example launches Chromium, opens a page, installs listeners, then navigates to a page that creates an iframe. Save it as frames.js and run it in a project with Puppeteer installed.

const puppeteer = require('puppeteer');

async function main() {
  const browser = await puppeteer.launch({ headless: true });
  try {
    const page = await browser.newPage();

    page.on('frameattached', frame => {
      console.log('attached', frame.url());
    });
    page.on('framenavigated', frame => {
      console.log('navigated', frame.url());
    });
    page.on('framedetached', frame => {
      console.log('detached', frame.url());
    });

    await page.setContent(`
      <!doctype html>
      <iframe src="data:text/html,first%20document"></iframe>
    `);

    const frame = page.frames().find(frame => frame.parentFrame() !== null);
    if (frame) {
      await frame.evaluate(() => {
        document.body.innerHTML = 'Frame content is ready';
      });
    }

    await page.setContent('<!doctype html><title>No iframe</title>');
  } finally {
    await browser.close();
  }
}

main().catch(error => {
  console.error(error);
  process.exitCode = 1;
});

The event sequence and exact URLs depend on the page and browser. The example illustrates listener placement and payload inspection; it does not assume a fixed event order beyond the browser operations requested.

What each frame event means

Event Meaning Use it when
frameattached A frame was attached to the page. You need to notice a newly created iframe or child browsing context.
framenavigated A frame navigated to a URL. You need to track URL transitions, including navigation in an existing frame.
framedetached A frame was detached from the page. You need to clean up state or record that a frame was removed.

All three callbacks receive a Frame. A frame’s url() is useful for identifying its current URL. A frame may be about to detach when the detachment callback runs, so avoid treating it as a stable, live execution context after removal.

Inspect the frame tree

Use page.frames() to get the current set of frames. It is a snapshot of the frames currently attached, not a subscription to future changes. The Frame API provides parentFrame(), childFrames(), page(), and url().

function describeFrame(frame) {
  const parent = frame.parentFrame();
  return {
    url: frame.url(),
    isMainFrame: parent === null,
    parentUrl: parent?.url() ?? null,
    childCount: frame.childFrames().length,
  };
}

for (const frame of page.frames()) {
  console.log(describeFrame(frame));
}

The main frame has no parent. Child frames can themselves have children, so recurse through childFrames() when you need to display the full hierarchy.

function logTree(frame, depth = 0) {
  console.log(`${'  '.repeat(depth)}${frame.url()}`);
  for (const child of frame.childFrames()) {
    logTree(child, depth + 1);
  }
}

logTree(page.mainFrame());

Wait for one particular frame

If you only need to proceed after one matching frame appears, use page.waitForFrame(urlOrPredicate, options). This is a one-time wait, while lifecycle listeners are better for a continuing stream. Consult the Page API for the current method signature and options for your installed Puppeteer version.

// Wait until a frame with a matching URL is present.
const frame = await page.waitForFrame(
  candidate => candidate.url().includes('widget.example'),
  { timeout: 10_000 },
);

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

Use a predicate when matching a stable part of the URL is more appropriate than exact equality. Set a finite timeout so a missing third-party frame does not leave the script waiting indefinitely. If you need to observe later URL changes or removal too, also register the lifecycle listeners.

Distinguish navigation from attachment

An iframe can attach and then navigate; an already attached frame can navigate again without a new attachment event. A URL change in a frame is therefore not evidence that a new frame was added. Puppeteer also documents History API URL changes as navigation for Frame.waitForNavigation(), so a navigation does not necessarily mean a full document reload.

When an action triggers navigation, start the wait and action together to avoid a race. For a child-frame action, use that frame’s navigation wait where appropriate.

const [response] = await Promise.all([
  page.waitForNavigation(),
  page.click('a'),
]);
console.log('Navigation response:', response?.status() ?? 'no response');

This pattern is for page navigation. If the click navigates an iframe, call waitForNavigation() on the relevant Frame instead. See Puppeteer’s page interactions guide.

Run code in newly created frame documents

For instrumentation that must execute inside each new document, page.evaluateOnNewDocument() serves a different purpose from Node-side lifecycle callbacks. Puppeteer says it runs after a document is created but before the page’s scripts run, including when a child frame attaches or navigates.

await page.evaluateOnNewDocument(() => {
  // This function runs in the page's browser context.
  Object.defineProperty(window, '__captureStarted', {
    value: Date.now(),
    configurable: true,
  });
});

This does not replace frameattached or framenavigated listeners when Node.js needs to receive frame lifecycle notifications. See the evaluateOnNewDocument API reference.

Cleanup, errors, and reliability

Listeners remain attached until removed or the page is closed. Remove listeners when a monitoring task ends, especially in long-running processes that reuse pages. This avoids duplicate logs and retained application state.

function onNavigated(frame) {
  console.log('navigated', frame.url());
}

page.on('framenavigated', onNavigated);

// Later, when this monitoring task is finished:
page.off('framenavigated', onNavigated);

For one-shot handling, the event emitter also provides once(). Keep handlers short: expensive asynchronous work inside a frequently fired callback can create backlogs or make failures harder to associate with the frame event. If a handler starts asynchronous work, handle its rejection explicitly.

Troubleshooting

Symptom Likely cause Fix
No event is observed. The listener was added after the frame change, or the frame was already present. Register before navigation or the triggering action. Inspect existing frames with page.frames().
frameattached does not fire when the iframe URL changes. Attachment and navigation are separate transitions. Listen for framenavigated as well.
The frame list does not update by itself. page.frames() returns the current snapshot. Use lifecycle events for future changes, then call page.frames() when you need a fresh snapshot.
A frame wait times out. The frame never matched, appeared after a race, or uses a URL different from the expected one. Install listeners before the action, log frame URLs, use a predicate that matches the actual URL, and choose an appropriate finite timeout.
A callback logs a stale or unexpected URL. The frame may have navigated again, or may be detaching. Log the event type and inspect the frame at callback time. Do not assume a frame remains attached after framedetached.
The navigation wait hangs or misses navigation. The wait started after the action, or the action did not navigate the frame expected. Start the wait and action concurrently with Promise.all; wait on the child Frame when it is the one navigating.
Multiple copies of each event appear. Listeners were registered repeatedly and not removed. Keep handler references and remove them with page.off() when monitoring ends.
Frame evaluation fails after a detach. The frame’s execution context no longer exists. Use the detach event to invalidate cached frame state and avoid evaluating in that frame after removal.

Performance and cost considerations

Frame events are lightweight notifications, but the work your handler performs determines the impact. Avoid repeatedly traversing the entire frame tree or making network requests for every event unless needed. Filter by URL or parent frame early, and keep a small map keyed by a frame’s identity only for as long as that frame remains attached. Remove its entry on detachment.

For reliability, register listeners before the page action, use explicit timeouts for one-time waits, and clean up listeners at the end of the task. Browser automation has runtime and infrastructure costs of its own; the dossier provides no benchmark or fixed cost figure for Puppeteer, so measure against your page mix and deployment environment.

Or skip the browser setup

If the goal is a screenshot of the rendered page rather than observing frame lifecycle events, ScreenshotNeo provides a screenshot API and MCP server. It does not replace Puppeteer frame event monitoring. One GET request returns an image or PDF; see the ScreenshotNeo API documentation.

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,
)
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}`);

Cookie banners, popups, and chat widgets are removed before the shot. Bot checks, blank pages, and failed loads are never billed. An 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. Sign up for free and get 1,000 screenshots a month with no card.

FAQ

Do frame events fire for nested iframes?

Yes. The lifecycle events are dispatched on the parent Page, including for child frames. Use parentFrame() and childFrames() to inspect nesting.

Can I listen for changes to a frame’s URL only?

Use framenavigated and inspect the callback frame’s url(). Filter out frames whose URL changes are irrelevant to your task.

Should I use an event listener or waitForFrame()?

Use an event listener to react to continuing changes. Use waitForFrame() when execution should pause until one matching frame is available.

Which Puppeteer versions support these APIs?

The cited API documentation includes stable Frame and Page references for Puppeteer 25.12.0, and the PageEvent reference is labeled Next. Check the API surface for the version installed in your project before relying on version-specific details.

Sources