How to Wait for Navigation in a Puppeteer Frame
Wait for the frame that will navigate, and arm the wait before triggering the action. Learn when to use navigation, selector, or locator waits.
Use frame.waitForNavigation() on the frame expected to navigate, and start it at the same time as the action that triggers navigation:
const [response] = await Promise.all([
frame.waitForNavigation(),
frame.click('a.my-link'),
]);
This arms the wait before the click can trigger navigation, avoiding a race. Puppeteer considers History API URL changes to be navigation. The promise resolves to the main resource response, or null when no response is available, such as for a same-document navigation. See the official Frame.waitForNavigation() reference.
1. Choose the frame that will navigate
Puppeteer represents DOM frames, including <iframe> elements, with its Frame class. Frames can be nested. Use the frame whose document is expected to change; a page-level wait is suitable only when the main frame is the intended target.
const mainFrame = page.mainFrame();
const childFrames = mainFrame.childFrames();
for (const child of childFrames) {
console.log(child.url());
}
For a nested iframe, inspect each frame’s childFrames() as needed. A frame reference can become detached if its iframe is removed, so obtain or validate the relevant frame around the interaction. The page emits frame lifecycle events, including attachment, navigation, and detachment. See the official Frame API.
2. Run the action and navigation wait together
Here is a complete Node.js example using Puppeteer. Replace the URL and selector with values for the site under test:
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' });
// Replace this with the frame URL or another reliable way to identify it.
const frame = page.frames().find(candidate =>
candidate.url().includes('/embedded-flow')
);
if (!frame) throw new Error('Target frame was not found');
const [response] = await Promise.all([
frame.waitForNavigation({ waitUntil: 'domcontentloaded', timeout: 30000 }),
frame.click('a.my-link'),
]);
console.log('Frame URL after action:', frame.url());
console.log('Main resource status:', response ? response.status() : 'no response');
} finally {
await browser.close();
}
})();
The Promise.all ordering is important: both operations are started together, so the navigation listener is installed before the click can cause the transition. Do not await the click first and start waitForNavigation() afterward; a fast navigation may already have happened.
If the iframe itself is created only after an earlier action, wait for the frame to appear before selecting it. If an interaction causes the iframe to be replaced, the original Frame can detach; locate the replacement frame before continuing.
3. Pick the right readiness condition
Navigation and page readiness are different conditions. Choose the wait that matches what the next operation needs:
| Need | Use | Why |
|---|---|---|
| The frame document or URL navigates | frame.waitForNavigation() |
Waits for a frame navigation lifecycle event. |
| A specific element appears or changes visibility | frame.waitForSelector() |
Expresses readiness in terms of the desired DOM state and works across navigations. |
| Perform an interaction with automatic waiting for the target’s state | A Puppeteer locator | Current interaction guidance recommends locators for selection and interaction. |
For example, if a frame’s URL does not change but the desired content appears asynchronously, wait for that content:
await frame.waitForSelector('.result-ready', {
visible: true,
timeout: 15000,
});
A Frame.waitForSelector() wait works across navigations. An ElementHandle.waitForSelector() is tied to the current element context and does not survive navigation or detachment. See the official Frame.waitForSelector() reference and page interaction guide.
4. Configure lifecycle and timeout behavior
waitForNavigation() accepts optional wait options. Set waitUntil to a lifecycle condition appropriate for the next step; for example, domcontentloaded can be enough when you will then wait for a specific application element:
const [response] = await Promise.all([
frame.waitForNavigation({
waitUntil: 'domcontentloaded',
timeout: 20000,
}),
frame.click('a.my-link'),
]);
await frame.waitForSelector('.result-ready', { visible: true });
Do not assume that any browser lifecycle event means the site’s application-specific asynchronous work is complete. Follow navigation with an element or application-condition wait if the next step depends on that state.
Selector waits support visible, hidden, signal, and timeout. Their documented default timeout is 30 seconds, configurable with Page.setDefaultTimeout(). Set a timeout that fits the operation, and use an abort signal when your workflow needs to cancel a pending selector wait. Check the API reference for the installed Puppeteer version because method signatures and option types can vary between versions.
5. Troubleshoot common failures
| Symptom | Likely cause | Fix |
|---|---|---|
| Navigation wait times out | The action did not navigate that frame, or the wait was attached after the action. | Confirm which frame changes URL or document, then start the wait and action together with Promise.all. |
| The main page loads, but the iframe wait times out | The main frame was used even though a child frame navigated, or vice versa. | Inspect page.frames(), identify the target frame, and wait on that frame. |
| The wait resolves but expected content is missing | Navigation completed before application data or a target element was ready. | Follow navigation with frame.waitForSelector() or an application-specific condition. |
The response is null |
A History API or other same-document URL change may not have a main resource response. | Check frame.url() or wait for the element that signals the desired state. |
| “Execution context was destroyed” or frame detached | The document navigated or its iframe was removed while code still used an old execution context or frame. | Wait for the relevant transition, reacquire the frame or element, and then continue. |
| Selector wait times out | The selector is wrong, the element is in another frame, or it never reaches the requested visibility state. | Check the selector in the target frame, use the appropriate visibility option, and set a realistic timeout. |
| Method or option type is unavailable | The installed Puppeteer version differs from the documentation version being followed. | Check the installed package version and its matching API reference. |
6. Reliability, performance, and cost
Waiting on the correct frame and registering the wait before the triggering action makes the flow less timing-sensitive. Use the narrowest readiness condition that is sufficient: waiting for a target selector can avoid holding the workflow until a later lifecycle event that it does not need. Choose explicit timeouts so a stalled page does not hold a job indefinitely, and handle timeouts and frame detachment as expected failure paths.
These waits run in your browser automation environment. Runtime and infrastructure cost depend on your browser, page, and workflow; Puppeteer’s API documentation does not provide a universal speed or cost figure. Avoid adding fixed delays when a navigation or DOM condition can represent readiness directly.
7. Or skip the browser setup
If your goal is a screenshot rather than browser automation, ScreenshotNeo is a website screenshot API with a single GET request. Its API accepts the target URL and 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://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,
)
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}`);
const fs = require('node:fs');
fs.writeFileSync('shot.webp', Buffer.from(await res.arrayBuffer()));
- Cookie banners are accepted and removed before capture; more than 60 known consent platforms, newsletter popups, and chat widgets are supported, and each cleanup step can be turned off.
- Bot checks and CAPTCHAs, blank pages, timeouts, failed loads, and cache hits are not billed. Responses report page verdict and billing status in headers.
- An MCP server provides
take_screenshot,get_page_info, andcapture_pdftools for Claude, Cursor, and other MCP clients. - The free plan includes 1,000 screenshots per month with no card; paid plans start at $5 for 3,000 shots. Every feature is on every plan.
Sign up for ScreenshotNeo and get 1,000 free screenshots a month, with no card.
8. FAQ
Does a History API URL change count as navigation?
Yes. Puppeteer documents History API URL changes as navigation. The wait can resolve with null because no main resource response was returned.
Should I use page.waitForNavigation() or frame.waitForNavigation()?
Use the method on the object representing the context expected to navigate: the page’s main frame for a top-level navigation, or the child Frame for an iframe navigation.
What should I wait for if the URL stays the same?
Wait for the meaningful UI state, such as a selector becoming visible, instead of requiring a navigation event.
Can I keep using a frame reference after its iframe is replaced?
No. A removed iframe detaches its frame. Find the replacement frame before performing more frame-scoped actions.


