Menu
Sign up

"Execution context was destroyed" in Puppeteer and Playwright: cause and fixes

Why Puppeteer and Playwright throw "Execution context was destroyed, most likely because of a navigation", reproduced on a test page, with four fixes.

Published November 8, 2026 · 5 min read

"Execution context was destroyed, most likely because of a navigation" is one of the most searched Puppeteer errors, and Playwright prints the same sentence. The message is accurate: something in your script was running in a page when the browser threw that page away for a new one. This page reproduces it on a small test site, shows where it comes from, and gives four fixes that we ran.

How we tested. On 2026-11-08, Puppeteer 25.7 and Playwright 1.64 against a local Node server with three pages: /redirect (a page that sends the browser on with location.href 200 ms after it loads, like a login hop or a locale switch), /form (a form with a submit button) and /final (the destination, answering after 300 ms like a real server).

What the error means

Every document a tab loads gets its own JavaScript world — the execution context. page.evaluate(), page.$eval(), page.waitForFunction() and element handles all live in one. When the tab navigates to a new document, Chrome destroys the old context and creates a new one. A call still running in the old context has nowhere to return to, and Puppeteer rejects it:

Error: Execution context was destroyed, most likely because of a navigation.

Playwright, for the same script:

page.evaluate: Execution context was destroyed, most likely because of a navigation.

What counts as a navigation here is a new document: a redirect by script or <meta http-equiv="refresh">, a form submit, a click on a normal link, location.reload(). We checked the other cases: history.pushState() and a hash change kept the context, and evaluate went on working; location.reload() threw the error.

Two ways to get it

1. The page moves on by itself. page.goto() returns when the first document has loaded, but that document then sends the browser elsewhere:

await page.goto('http://localhost:8097/redirect', { waitUntil: 'load' });
// The page navigates 200 ms later, while this is still running:
const title = await page.evaluate(async () => {
  await new Promise((r) => setTimeout(r, 1000));
  return document.title;
});
// Error: Execution context was destroyed, most likely because of a navigation.

On real sites this is a consent platform reloading the page after "accept", a sign-in check that redirects, a geo or language redirect done in JavaScript, or a bot check that passes and loads the real page.

2. Your own click navigates. You click a submit button or a link and run the next step at once:

await page.goto('http://localhost:8097/form');
await page.click('#go');              // submits the form
await page.evaluate(...);             // runs in the old page, then it goes
// Error: Execution context was destroyed, most likely because of a navigation.

This one is a race. With our server answering instantly, the same script passed: the new page was already there when evaluate started. With the server taking 300 ms, it failed in 8 of 10 runs. That is why the error often shows up in CI or against production and rarely on localhost.

A quieter cousin: a screenshot taken during the redirect does not fail at all. After goto on /redirect, page.screenshot() saved the "Loading…" page and the title read first — a wrong picture with no error.

Four fixes

Each was run against the same pages and returned the final page's title.

Fix 1: wait for the navigation you trigger, starting before the click.

await Promise.all([
  page.waitForNavigation(),
  page.click('#go'),
]);
const title = await page.evaluate(() => document.title); // "final"

The order matters: waitForNavigation() must be listening before the click; started after it, a fast navigation may already be over, and the wait then sits until its timeout. Puppeteer's docs show the same Promise.all pattern: it "is useful when you run code that will indirectly cause the page to navigate."

Fix 2: wait for the page's own redirect.

await page.goto('http://localhost:8097/redirect', { waitUntil: 'load' });
await page.waitForNavigation({ waitUntil: 'load' });

Use it only when you know the redirect always happens; otherwise it waits for its full timeout.

Fix 3: wait for something only the final page has. The most robust: you do not need to know how many hops there are.

await page.goto('http://localhost:8097/redirect');
await page.waitForSelector('#done');

waitForSelector survives navigations: it keeps looking in each new document until the element appears (waitForSelector).

Fix 4 (Playwright): waitForURL and locators.

await page.goto('http://localhost:8097/redirect');
await page.waitForURL('**/final');
// or let a locator wait for its element:
const text = await page.locator('#done').textContent(); // "Final page"

Playwright's navigation guide: "Clicking an element could trigger multiple navigations. In these cases, it is recommended to explicitly page.waitForURL() to a specific url."

What not to do: wrapping evaluate in try/catch and retrying hides the race without fixing it, and a fixed sleep (setTimeout(3000)) is slower when the site is fast and still fails when it is slow. A retry is fine as a last guard around the whole step, once the step waits for the right thing.

Checklist when you see the error

  1. Log page.url() just before the failing call and in the catch block. A different URL means a navigation happened in between.
  2. Find what navigated: a click in your script (fix 1), a redirect in the page (fix 2 or 3), a consent banner or a login (fix 3, and see cookie banners in screenshots).
  3. Replace waits on time with waits on state: an element, a URL, a response. Waiting for page load in Puppeteer measures the waitUntil options side by side.
  4. Keep element handles short-lived. A handle taken before a navigation points into the destroyed context; query again after it.

When the job is just a screenshot

If all your script does is open a URL and save a picture, this whole class of problems is something our screenshot API handles for you: before it captures, it waits for any navigation that a click, a meta refresh or a script has started to land, and it never photographs a refused or failed document. Consent platforms that reload after "accept" are waited for the same way.

curl -X POST "https://shareglint.com/v1/screenshots?wait=true" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://example.com/", "wait_for": "#content", "block_cookie_banners": true}'

wait_for waits for a selector of the page you want, like fix 3. A viewport screenshot is 1 render of the quota, a full page 2 (pricing). For pages behind a login or multi-step flows, keep your own Puppeteer or Playwright: the API only takes public URLs. More: Puppeteer screenshots, Playwright screenshots.

FAQ

What does "Execution context was destroyed, most likely because of a navigation" mean?

Your script was running JavaScript in a page (page.evaluate, page.$eval, a waitForFunction) when the browser left that page for a new document: a redirect, a form submit, a reload. The old document's JavaScript world is thrown away, and the call that was running in it fails with this error.

How do I fix execution context was destroyed in Puppeteer?

Wait for the navigation before you evaluate. If you trigger it with a click, start waiting first: await Promise.all([page.waitForNavigation(), page.click(selector)]). If the page sends you on by itself, wait for the final page: page.waitForNavigation() or page.waitForSelector() of an element only the final page has.

Does history.pushState cause this error?

No. In our test, pushState and a hash change kept the same document and evaluate went on working; location.reload() threw the error. Single-page apps that route with the History API do not destroy the context, a full page load does.

Does Playwright have the same error?

Yes, with the same wording: "page.evaluate: Execution context was destroyed, most likely because of a navigation." Playwright's locators wait for their element and retry, so code written with locators and page.waitForURL() rarely meets it; raw page.evaluate calls still can.