Sooner or later, if you work with Puppeteer, you'll hit "Navigating frame was detached." At first the error seems random. Then you notice it only happens on pages with iframes. Later you discover that Chrome isolates cross-origin iframes into separate OS processes — by design, for security — and that's what kills your frame reference mid-operation.
The error comes in two forms:
Error: Navigating frame was detached
And the longer variant:
Error: Attempted to use detached Frame
Both mean the frame your code was talking to no longer exists in the page's frame tree. This post breaks down why it happens, the five scenarios that trigger it, and working fixes for each one.
What "frame was detached" actually means
Every browser tab has a frame tree. The main page is the root frame. Each <iframe> on the page is a child frame. When you interact with a frame through Puppeteer, you're holding a reference to a specific node in that tree.
The reference goes stale when Chrome removes the frame from the tree. That removal can happen for three reasons: the page's JavaScript deleted the iframe element, the iframe navigated to a different origin and Chrome swapped it to a separate process, or the entire page navigated away. Once the frame detaches, any Puppeteer method called on that frame object throws.
Under the hood, Chrome's DevTools Protocol fires a Page.frameDetached event with a reason field. Two values matter here: "remove" means the iframe was deleted from the DOM, and "swap" means Chrome moved it to an out-of-process iframe (OOPIF) due to cross-origin navigation. Puppeteer's LifecycleWatcher sees the detachment and resolves the navigation promise with an error.
The five scenarios that trigger this error
Not every frame detachment has the same cause. Some are Chrome doing its job, some are your framework pulling the rug, and some are just bad timing. Here's each one with what's actually happening under the hood.
1. Cross-origin iframe navigation (the OOPIF problem)
Chrome's Site Isolation architecture (shipped in Chrome 67) places documents from different origins into separate OS processes. When an iframe navigates from your-app.com to accounts.google.com for OAuth, Chrome destroys the current frame's renderer process and creates a new one. From Puppeteer's perspective, the frame detaches and a new one attaches with a different ID. Any reference to the old frame is dead.
This is the most common trigger. OAuth flows, payment gateways like Stripe's 3D Secure, and SSO redirects all cause it. The frame still exists on the page, but Chrome swapped it to a different process and Puppeteer lost its handle.
2. SPA re-renders that unmount iframes
React, Next.js, Vue, and Angular can unmount and remount components during state changes. If the component contains an iframe, the old iframe element gets removed from the DOM and a new one gets inserted. Your Puppeteer Frame reference points to the old element that no longer exists.
// This breaks when React re-renders the component containing the iframe
const frame = page.frames().find(f => f.name() === 'widget');
await someActionThatTriggersReRender();
await frame.click('#button'); // Error: Attempted to use detached Frame
The fix is never to hold frame references across operations that might trigger re-renders.
3. Page navigation during iframe interaction
Calling page.goto() while you're still working with an iframe on the current page. Or the page itself navigates (via window.location, a <meta http-equiv="refresh">, or a server redirect) while your script is mid-operation on a child frame. The main frame navigates, all child frames detach.
4. Dynamic iframes that self-destruct
Ad networks, chat widgets, and cookie consent banners create iframes that remove themselves after completing their task. Stripe Elements destroys and recreates its iframe after tokenization. A consent iframe might remove itself once the user clicks "Accept." If your code grabbed a reference before the destruction, it's now stale.
5. PDF generation on pages with iframes
Generating PDFs with page.pdf() on complex pages can trigger frame detachment. The print media query causes some iframes to unload, and the PDF rendering pipeline interacts with frames that are mid-lifecycle. Issue #11429 documents this. There's no clean workaround — if your page has iframes and you need PDFs, remove the iframes via page.evaluate() before calling page.pdf().
Fix 1: Re-acquire frame references after every action
The most common mistake is caching a frame reference and reusing it across multiple steps. Frames can detach and reattach between any two operations.
// BAD: holding a stale reference
const frame = page.frames().find(f => f.url().includes('checkout'));
await frame.type('#card', '4242424242424242');
await frame.click('#submit'); // might detach between type and click
await frame.waitForSelector('.success'); // definitely stale if iframe reloaded
// GOOD: re-acquire before each interaction
async function getFrame(page, urlPattern) {
const frame = page.frames().find(f => f.url().includes(urlPattern));
if (!frame) throw new Error(`Frame matching "${urlPattern}" not found`);
return frame;
}
let frame = await getFrame(page, 'checkout');
await frame.type('#card', '4242424242424242');
frame = await getFrame(page, 'checkout'); // fresh reference
await frame.click('#submit');
frame = await getFrame(page, 'checkout'); // fresh again
await frame.waitForSelector('.success');
Verbose? Yes. But each lookup is a few microseconds, and it saves you from debugging a non-deterministic error that only shows up in CI.
Fix 2: Use Promise.all for navigation-triggering actions
When clicking something that causes navigation (a link inside an iframe, a form submit, an OAuth redirect), the frame can detach between the click and your waitForNavigation call. The solution is to start listening before the action:
// BAD: frame detaches between click and waitForNavigation
await frame.click('#oauth-button');
await page.waitForNavigation(); // too late — frame already detached
// GOOD: listen before acting
await Promise.all([
page.waitForNavigation({ waitUntil: 'domcontentloaded' }),
frame.click('#oauth-button'),
]);
Fix 3: Retry wrapper for non-deterministic detachments
Some frame detachments are timing-dependent. The same page loads fine 9 out of 10 times, then the iframe races Chrome's process allocation on the 10th. A retry wrapper handles this:
async function withRetry(fn, { retries = 3, delay = 1000 } = {}) {
for (let attempt = 0; attempt < retries; attempt++) {
try {
return await fn();
} catch (error) {
const isDetached = error.message.includes('frame was detached') ||
error.message.includes('detached Frame');
if (!isDetached || attempt === retries - 1) throw error;
await new Promise(r => setTimeout(r, delay * (attempt + 1)));
}
}
}
// Usage
await withRetry(async () => {
await page.goto('https://example.com/oauth-callback');
});
I'd keep retries at 2-3. If it fails consistently, the problem is architectural and you need one of the other fixes.
Fix 4: Disable site-per-process (quick fix, trade-offs apply)
The nuclear option for cross-origin iframe detachments:
const browser = await puppeteer.launch({
args: ['--disable-features=site-per-process']
});
This tells Chrome not to isolate cross-origin iframes into separate processes. No process swap means no frame detachment on cross-origin navigation. The error disappears.
For screenshot pipelines capturing known URLs, this is a pragmatic fix. For anything processing untrusted input, keep site isolation on and use the retry + re-acquire approach instead.
Fix 5: Move DOM work into page.evaluate
Every Puppeteer method like frame.click() or frame.type() makes a round-trip from Node.js to the browser via CDP. Between any two round-trips, the frame can detach. Moving all DOM operations into a single page.evaluate() call eliminates the gap:
// Instead of multiple frame method calls:
await frame.type('#email', '[email protected]');
await frame.type('#password', 'secret');
await frame.click('#submit');
// One atomic evaluate call:
await frame.evaluate(() => {
document.querySelector('#email').value = '[email protected]';
document.querySelector('#password').value = 'secret';
document.querySelector('#submit').click();
});
The downside: evaluate() doesn't wait for navigation or trigger Puppeteer's auto-wait logic. You'll need to handle waits yourself. But for filling forms inside iframes that might remount, it's more reliable than multiple CDP round-trips.
How Playwright handles this differently
Playwright has a fundamentally different approach to frames. Instead of giving you a live Frame object that goes stale, it offers frameLocator() — a lazy reference that re-resolves on every interaction.
// Puppeteer: live handle, breaks on remount
const frame = page.frames().find(f => f.name() === 'payment');
await frame.fill('#card', '4242...'); // dies if iframe remounted
// Playwright: lazy locator, survives remount
const frame = page.frameLocator('#payment-iframe');
await frame.locator('#card').fill('4242...');
// if the iframe remounts between lines, frameLocator re-finds it
The difference is architectural. Puppeteer's Frame object points to a specific frame instance in Chrome's process tree. When that instance dies, the object is useless. Playwright's frameLocator is a selector that gets evaluated fresh on every action call. If the iframe unmounts and remounts (same selector, new DOM node), Playwright picks up the new one automatically.
If you use Playwright instead of Puppeteer, you may hit a related error: "Target page, context or browser has been closed." That one is about the browser context dying, not a specific frame — different root cause, different fix.
Why this error is worse in serverless
AWS Lambda, Cloud Functions, and Vercel reuse execution environments across invocations. If a browser instance crashes or a frame detaches during one invocation, the stale state carries over to the next one. Issue #13236 documents exactly this: the "frame detached" error persists across Lambda invocations because the crashed browser instance stays in memory.
// Guard against stale browser instances in Lambda
let browser;
async function getBrowser() {
if (browser) {
try {
await browser.version(); // test if browser is alive
} catch {
browser = null; // dead — force new instance
}
}
if (!browser) {
browser = await puppeteer.launch({
args: ['--no-sandbox', '--disable-setuid-sandbox']
});
}
return browser;
}
If the browser is still alive but a specific page or frame is stale, close and reopen the page rather than reusing it. In Lambda, a fresh browser.newPage() is cheap. Debugging a stale frame reference across warm starts is not.
Debugging which frame detached and why
When the error hits and you don't know which frame caused it, add frame event listeners before the failing operation:
page.on('frameattached', frame =>
console.log(`+ attached: ${frame.url() || '(about:blank)'}`));
page.on('framedetached', frame =>
console.log(`- detached: ${frame.url()}`));
page.on('framenavigated', frame =>
console.log(`~ navigated: ${frame.url()}`));
// Log the current frame tree
console.log('Frames:', page.frames().map(f =>
`${f.name() || '(unnamed)'}: ${f.url()}`
));
Run this, reproduce the error, and check which frame detached right before the failure. If you see a - detached immediately followed by a + attached with a different URL, that's an OOPIF swap. If you see - detached with no reattach, the iframe was removed from the DOM.
The frame tree log also helps catch a common mistake: your code is targeting an ad iframe or analytics tracker instead of the frame you actually want. Name your target frames by URL pattern, not by index.
Or skip frame management entirely
Frame detachment errors exist because your code is managing browser internals that change between Chrome versions. Each iframe on the page is another frame that can detach and another process Chrome might swap mid-operation.
A screenshot API renders the full page, iframes included, and returns the finished image. You don't manage frame references or worry about OOPIF swaps. The API handles the browser internals.
curl "https://api.screenshotrun.com/v1/screenshots/capture?\
url=https://example.com/page-with-iframes\
&format=png\
&wait_for_selector=.iframe-content-loaded" \
-H "Authorization: Bearer YOUR_API_KEY" \
--output screenshot.png
The page had three cross-origin iframes, two of which would have triggered OOPIF swaps in Puppeteer. The API doesn't care. It waits for the selector, captures the composited result, and returns the image.
Start with 200 free screenshots on the free tier — no card, no commitment.
FAQ#
It means the iframe your code was interacting with was removed from Chrome's frame tree. This happens when Chrome moves a cross-origin iframe to a separate OS process (Site Isolation), when JavaScript removes the iframe from the DOM, or when the parent page navigates away. Any Puppeteer method called on the old frame reference will throw this error.
Both errors mean the frame no longer exists in the page's frame tree. "Navigating frame was detached" fires during page.goto() or frame.goto() calls. "Attempted to use detached Frame" fires when calling methods like click() or type() on a stale frame reference. Same root cause, different trigger points.
The most reliable fix is to re-acquire frame references before each interaction instead of caching them. Use page.frames().find() to get a fresh reference before every click, type, or waitForSelector call. For navigation-triggering actions, wrap the click and waitForNavigation in Promise.all() so you start listening before the frame can detach.
Yes, launching Chrome with --disable-features=site-per-process prevents cross-origin iframe process swaps, which eliminates most frame detachment errors. However, this disables Site Isolation, a security feature that protects against Spectre-class attacks. It's acceptable for scraping known public URLs but not recommended when processing untrusted input.
Playwright uses frameLocator(), a lazy selector that re-resolves on every interaction. If an iframe unmounts and remounts, Playwright automatically picks up the new DOM node. Puppeteer's Frame object points to a specific frame instance — when that instance dies, the object becomes useless and throws.
Frame detachment is timing-dependent. Chrome's process allocation for cross-origin iframes races against your Puppeteer commands. The same page might load fine 9 out of 10 times, then the iframe swaps at exactly the wrong moment on the 10th run. A retry wrapper with 2-3 attempts handles these non-deterministic cases.