ScreenshotRender
← Back to blog
Engineering

Playwright Execution Context Was Destroyed: Fix the Error

Robert Belt·10 min read
Updated On :
Orange minimalist illustration of a Playwright page handle losing its JavaScript context after a navigation

Short answer: the page navigated between the moment Playwright handed you the handle and the moment you called evaluate on it, so the JavaScript execution context that handle pointed to no longer exists. The fix is to wait on the final URL or the final load state before touching the page, not on whatever page.goto happened to return.

By the end of this you will know which of three fixes matches the version of the error you are hitting, why the most common one (wrap it in a retry) is the worst one, and when the cleanest fix is to stop driving a browser yourself.

What does Playwright execution context was destroyed actually mean?

It means the V8 context backing your page handle was torn down by a navigation, so any page.evaluate or element-handle call that depended on it fails with the message Execution context was destroyed, most likely because of a navigation. Chromium creates a fresh execution context for every navigation, and the old one is garbage-collected the moment the new document commits. Playwright is telling you, accurately, that the world your handle lived in is gone.

This is painful in screenshot scripts specifically, because the typical pattern is await page.goto(url) followed immediately by await page.screenshot(). On a static blog post that works forever. On a real site, the page often redirects once or twice after goto resolves, and the screenshot call lands on a destroyed context. If your only goal is a clean PNG of a public page, a hosted screenshot API like ScreenshotRender sidesteps this entire class of error because the navigation race happens on its infrastructure, not yours. If you need the full browser for scraping or testing, the rest of this post is the fix.

Why does this error keep happening after page.goto resolves?

Because page.goto resolves on the first navigation commit, not on the last one. The promise returned by page.goto resolves when Chromium commits the initial document and reaches the wait condition you asked for (default load). It does not wait for client-side redirects, meta refreshes, framework hydration that triggers a route change, or interstitial-then-real-page flows from Cloudflare and DataDome.

The four common offenders:

  • Client-side redirects. A landing page that checks a cookie and does window.location.replace during the first JS tick.
  • OAuth and auth handoffs. The provider bounces the browser through two or three short-lived URLs before settling on the destination.
  • SPA route changes during hydration. React and Vue apps that mount a router which immediately replaces the initial route based on auth state or a feature flag.
  • Anti-bot interstitials. Cloudflare Turnstile and similar challenges serve an interstitial first, then navigate to the real page once the challenge passes. Two navigations, one expected.

In every case page.goto kept its promise (it did wait for the first load) and Chromium kept its invariant (the new document gets a new context). The race is between those two facts, and your script is the one that needs to know about it.

How do you fix execution context was destroyed in Playwright?

Pick the most specific signal your page actually emits, then wait on that signal before doing anything that depends on the page handle. The three fixes below are ordered from loosest to tightest, and you should reach for the tightest one your site will tolerate.

Fix 1: wait for the load state to settle

Replace the implicit wait inside page.goto with an explicit page.waitForLoadState call set to networkidle. After await page.goto(url), add await page.waitForLoadState('networkidle') and most client-side redirects will have fired and resolved before you touch the page. Set the timeout explicitly: page.waitForLoadState('networkidle', { timeout: 15000 }) so a chatty site that never reaches network idle fails in 15 seconds instead of stalling forever.

This is the loosest fix because networkidle is a heuristic (no network activity for 500 ms), not a guarantee the navigation chain is done. It catches 70 to 80 percent of cases and is the right default. It does not catch a deliberately delayed redirect that fires three seconds after idle.

Fix 2: wait for the final URL

When you know the URL you expect to end up on, wait for it directly with page.waitForURL. Pass either a literal string, a glob, or a regex: await page.waitForURL(/dashboard/) resolves only when the URL matches, regardless of how many intermediate navigations the page goes through. This is the right fix for OAuth flows and any page that redirects a known number of times to a known destination.

Once waitForURL resolves you are guaranteed to be in the destination document. Any element handles you grab after this point belong to the final context and will survive an evaluate call. This is the fix that actually survives Cloudflare interstitials, because the post-challenge navigation has a predictable URL pattern (your target) and the pre-challenge page does not.

Fix 3: wait on a real condition in the page

When you do not know the final URL but you do know what the finished page looks like, use page.waitForFunction with a predicate that returns truthy when the page is in the state you want. Example: await page.waitForFunction(() => document.readyState === 'complete' && !!document.querySelector('main')). The predicate runs inside the page, so it automatically re-evaluates against the new context after each navigation, which is exactly the property you want.

This is the tightest fix because it ties your wait to a DOM-level fact (a specific element exists, a global variable is set, the body has a class) instead of a network heuristic. It is also the only fix that works on pages that never reach network idle because of a long-polling websocket or an analytics beacon that fires every two seconds.

Skip the Chromium build, the Cloudflare fight, and the navigation race.

If you are only inside Playwright to grab a screenshot, one GET request to ScreenshotRender returns a hosted PNG with cookie banners and ads already stripped. Cloudflare, JavaScript, and redirect chains are handled on our infrastructure. 100 free screenshots per month, no credit card.

Get an API key

When does the fix fail?

Three scenarios where none of the three fixes will save you, and what to do instead. Knowing the failure modes is the difference between a script that runs for a year and one that breaks the first time a site changes.

  • Anti-bot pages that never let Chromium through. If Cloudflare or DataDome serves a challenge that vanilla headless Chromium cannot solve, your waitForURL will time out because the destination URL never loads. The waits are correct, the browser fingerprint is the problem. Either run a stealth plugin and a residential proxy, or use a service that bakes this in. ScreenshotRender bundles Cloudflare bypass on the Hobby plan and above (see our Cloudflare screenshot guide for what changes).
  • Infinite redirect loops. A site that redirects between two URLs based on a cookie you never set will keep navigating forever. waitForURL will time out and you will see the same error reappear inside the loop. Detect the loop by counting navigations with page.on('framenavigated', ...) and bailing after, say, five.
  • Sites that genuinely never reach network idle. Apps with a heartbeat websocket, a polling analytics call every second, or a video player that streams continuously will never trigger networkidle. Use Fix 3 (waitForFunction on a DOM signal) and skip the network heuristic entirely.

Is this the same error as page.waitForTimeout is not a function?

No, those are unrelated. Execution context was destroyed is a runtime error caused by a navigation race. page.waitForTimeout is not a function is a version-mismatch error caused by Puppeteer removing the method in v22, and the fix is to call new Promise(r => setTimeout(r, ms)) or to use page.waitForFunction with a timeout instead. Different errors, different fixes; if you have both, upgrade Puppeteer first so the second error stops masking the first.

Common questions about the Playwright execution context error

Does this error happen in Puppeteer too?

Yes, with the same root cause and almost the same wording. Puppeteer throws Execution context was destroyed, most likely because of a navigation when an evaluate or a handle method runs across a page navigation. The fixes translate directly: wait for the final URL with page.waitForNavigation or page.waitForFunction(() => location.href === expected), and re-query any element handles after the navigation settles. The Chromium DevTools Protocol underneath is identical, so the rule is the same in both libraries: never hold a handle across a navigation.

Does page.waitForNavigation prevent the error?

Only if you are sure exactly one navigation happens. waitForNavigation resolves on the next navigation event, but pages that redirect twice, hydrate with client-side routing, or fire a meta refresh will navigate again after waitForNavigation returns. For multi-hop flows, page.waitForURL with the final URL pattern is safer because it only resolves when the URL actually matches what you expect, not when any navigation happens to fire.

Why does it only happen on some pages?

Because most static pages do not navigate after page.goto resolves. The error fires on pages that trigger a second navigation themselves: client-side redirects, OAuth handoffs, single page apps that swap routes during hydration, marketing pages that A/B test by redirecting based on a cookie, and Cloudflare or DataDome interstitial pages that swap to the real page after passing the challenge. If your target is a static blog post, you will never see this error.

Is it safe to just retry the evaluate?

Sometimes, but it hides the real problem. Wrapping page.evaluate in a try/catch and re-running it usually works because by the second attempt the navigation has settled, but you have just papered over a race that will reappear under load or on a slower page. Prefer waiting on the explicit signal (waitForLoadState, waitForURL, or waitForFunction) so the script becomes deterministic instead of relying on retry luck.

Should I switch to a screenshot API instead?

If the whole reason you are inside Playwright is to capture a screenshot of a public page, yes. ScreenshotRender takes a URL and returns a hosted PNG with one GET request, and the navigation race that causes this error happens inside its infrastructure instead of yours. The full call is one line: https://screenshotrender.com/api/v1/screenshot?apiKey=YOUR_API_KEY&url=https://en.wikipedia.org/wiki/HTTP&fullPage=true. The free tier is 100 screenshots per month with no credit card. If your Playwright script is doing actual scraping, form-filling, or end-to-end testing, keep Playwright and apply the three fixes above. See our comparison of screenshot APIs if you are evaluating options.

The honest takeaway: this error is not a Playwright bug, it is an accurate report that your script outran the page. Wait on the specific signal that proves the page is done navigating and the error disappears. The looser the wait, the more flakiness you will eat under load.

Keep reading