Skip to content
Independent guides for QA & test automationRSSEditorial policy
QA Vibes

Playwright "page.goto: net::ERR_ABORTED; maybe frame was detached?": What Aborted the Navigation

Playwright's "page.goto: net::ERR_ABORTED", reproduced nine ways on a one-file local app and run in Chromium, Firefox and WebKit: a 204 response, two navigations at once, a test timeout, a page closed mid-load, route.abort() and a removed iframe. Why a download now says "Download is starting" instead, where the "maybe frame was detached?" suffix comes from, and the fix for each cause.

QA Vibes EditorialPublished 15 minTested with Playwright 1.63.0, Chromium 153, Firefox 155, WebKit 26.6, Node.js 20.20.2Revision history ↓

Key takeaways

  • net::ERR_ABORTED means the browser stopped loading the page goto was waiting for. Ask what stopped it: a second navigation, a closed page, a test timeout, an aborting route, or a response with no page.
  • "; maybe frame was detached?" is a guess, added whenever the browser reports the request as cancelled. Chromium showed it for a timeout and a closed page, not for an iframe that really was removed.
  • A goto to a file download no longer fails with net::ERR_ABORTED: all three browsers said "page.goto: Download is starting". Wait for the download event, or fetch the file with the request API.
  • One mistake, three messages: a second goto made Chromium say net::ERR_ABORTED, Firefox "is interrupted by another navigation", and WebKit "Test ended.". Search for the cause, not the string.
  • When ERR_ABORTED follows "Test timeout of 30000ms exceeded.", the timeout is the cause. Give the slow goto a timeout of its own and the error says so: "page.goto: Timeout 3000ms exceeded."
Contents (10 sections)

Introduction

"Error: page.goto: net::ERR_ABORTED; maybe frame was detached?" means the browser stopped loading the page that page.goto was waiting for. It doesn't say why. The page may have been closed, the test may have run out of time, another navigation may have replaced it, or the server's response may not have been a page at all. The "maybe frame was detached?" part is a guess Playwright adds, and it is often wrong.

This page reproduces the error nine ways and shows what each browser printed. Everything was run on 19 October 2026 with Playwright 1.63.0 and Node.js 20.20.2 in Chromium 153, Firefox 155 and WebKit 26.6, against a one-file local app you can run yourself. Each browser ran the spec three times and printed the same messages every time.

The app and the spec

The app has a page that downloads a CSV file, an endpoint that answers 204 No Content, a page that takes as long as you ask it to, a page that redirects itself with JavaScript, and a page with an iframe. Save it as server.mjs:

// A one-file app with pages that make page.goto fail in different ways.
import http from "node:http";
 
const html = (title, body = "") =>
  `<!doctype html><title>${title}</title><h1>${title}</h1>${body}`;
 
http
  .createServer((req, res) => {
    const url = new URL(req.url, "http://localhost");
    switch (url.pathname) {
      case "/":
        res.setHeader("content-type", "text/html");
        return res.end(html("Shop", '<a href="/report.csv">Download report</a>'));
      case "/report.csv":
        res.setHeader("content-type", "text/csv");
        res.setHeader("content-disposition", 'attachment; filename="report.csv"');
        return res.end("id,total\n1,15.99\n");
      case "/ping":
        res.statusCode = 204;
        return res.end();
      case "/hops":
        // The page navigates away by itself while it is still loading.
        res.setHeader("content-type", "text/html");
        return res.end(html("Hops", '<script>location.href = "/landing"</script><img src="/slow-img">'));
      case "/landing":
        res.setHeader("content-type", "text/html");
        return res.end(html("Landing"));
      case "/slow-img":
        return setTimeout(() => res.end(), 2000);
      case "/slow": {
        const ms = Number(url.searchParams.get("ms") ?? 3000);
        res.setHeader("content-type", "text/html");
        return setTimeout(() => res.end(html("Slow")), ms);
      }
      case "/frames":
        res.setHeader("content-type", "text/html");
        return res.end(html("Frames", '<iframe name="pay" src="/landing"></iframe>'));
      default:
        res.statusCode = 404;
        res.setHeader("content-type", "text/html");
        return res.end(html("Not found"));
    }
  })
  .listen(4323, () => console.log("app on http://localhost:4323"));

The config starts it and runs every test in all three browsers:

import { defineConfig, devices } from "@playwright/test";
 
export default defineConfig({
  testDir: "./tests",
  reporter: "list",
  use: { baseURL: "http://localhost:4323" },
  webServer: { command: "node server.mjs", url: "http://localhost:4323" },
  projects: [
    { name: "chromium", use: { ...devices["Desktop Chrome"] } },
    { name: "firefox", use: { ...devices["Desktop Firefox"] } },
    { name: "webkit", use: { ...devices["Desktop Safari"] } },
  ],
});

And tests/aborted.spec.ts makes the mistakes:

import { test, expect } from "@playwright/test";
 
test("goto a file download", async ({ page }) => {
  await page.goto("/report.csv");
});
 
test("goto a 204 No Content", async ({ page }) => {
  await page.goto("/ping");
});
 
test("the page navigates away while loading", async ({ page }) => {
  await page.goto("/hops");
  await expect(page.getByRole("heading")).toHaveText("Landing");
});
 
test("a second goto before the first finished", async ({ page }) => {
  page.goto("/slow?ms=3000");
  await page.goto("/landing");
});
 
test("two gotos, both awaited together", async ({ page }) => {
  await Promise.all([page.goto("/slow?ms=3000"), page.goto("/landing")]);
});
 
test("the test times out during goto", async ({ page }) => {
  test.setTimeout(5_000);
  await page.goto("/slow?ms=10000");
});
 
test("the page is closed during goto", async ({ page }) => {
  setTimeout(() => page.close(), 1000);
  await page.goto("/slow?ms=3000");
});
 
test("route.abort() on the document", async ({ page }) => {
  await page.route("**/landing", (route) => route.abort());
  await page.goto("/landing");
});
 
test("route.abort('aborted') on the document", async ({ page }) => {
  await page.route("**/landing", (route) => route.abort("aborted"));
  await page.goto("/landing");
});
 
test("an iframe removed during its goto", async ({ page }) => {
  await page.goto("/frames");
  const frame = page.frame({ name: "pay" })!;
  setTimeout(() => page.evaluate(() => document.querySelector("iframe")!.remove()), 1000);
  await frame.goto("/slow?ms=3000");
});
 
test("clicking a download link (no goto)", async ({ page }) => {
  await page.goto("/");
  await page.getByRole("link", { name: "Download report" }).click();
  await expect(page.getByRole("heading")).toHaveText("Shop");
});

In Chromium, nine of the eleven tests failed:

Running 11 tests using 1 worker
 
  x   1 [chromium] › tests\aborted.spec.ts:3:5 › goto a file download (442ms)
  x   2 [chromium] › tests\aborted.spec.ts:7:5 › goto a 204 No Content (209ms)
  ok  3 [chromium] › tests\aborted.spec.ts:11:5 › the page navigates away while loading (309ms)
  x   4 [chromium] › tests\aborted.spec.ts:16:5 › a second goto before the first finished (1.2s)
  x   5 [chromium] › tests\aborted.spec.ts:21:5 › two gotos, both awaited together (178ms)
  x   6 [chromium] › tests\aborted.spec.ts:25:5 › the test times out during goto (10.0s)
  x   7 [chromium] › tests\aborted.spec.ts:30:5 › the page is closed during goto (1.2s)
  x   8 [chromium] › tests\aborted.spec.ts:35:5 › route.abort() on the document (1.3s)
  x   9 [chromium] › tests\aborted.spec.ts:40:5 › route.abort('aborted') on the document (213ms)
  x  10 [chromium] › tests\aborted.spec.ts:45:5 › an iframe removed during its goto (1.2s)
  ok 11 [chromium] › tests\aborted.spec.ts:52:5 › clicking a download link (no goto) (252ms)

Firefox and WebKit failed the same nine tests and passed the same two, but often with different messages.

What each browser said

Cause Chromium 153 Firefox 155 WebKit 26.6
goto a file download Download is starting Download is starting Download is starting
goto a 204 No Content net::ERR_ABORTED at …/ping NS_BINDING_ABORTED; maybe frame was detached? Aborted: 204 No Content
A second goto before the first finished net::ERR_ABORTED at …/slow?ms=3000 Navigation to "…/slow?ms=3000" is interrupted by another navigation to "…/landing" Test ended.
Two gotos in one Promise.all net::ERR_ABORTED at …/slow?ms=3000 NS_BINDING_ABORTED No error: hung until the 30-second test timeout
The test times out during goto net::ERR_ABORTED; maybe frame was detached? Test timeout of 5000ms exceeded. Test timeout of 5000ms exceeded.
The page is closed during goto net::ERR_ABORTED; maybe frame was detached? Target page, context or browser has been closed Target page, context or browser has been closed
route.abort() on the document net::ERR_FAILED at …/landing NS_ERROR_FAILURE Blocked by Web Inspector
route.abort("aborted") on the document net::ERR_ABORTED at …/landing NS_ERROR_ABORT Blocked by Web Inspector
An iframe removed during its goto frame.goto: net::ERR_ABORTED at …/slow?ms=3000 frame.goto: NS_BINDING_ABORTED; maybe frame was detached? frame.goto: Load request cancelled; maybe frame was detached?

Every message starts with page.goto: (or frame.goto:). net::ERR_ABORTED is Chromium's name for "the load was stopped". In Firefox the same thing is NS_BINDING_ABORTED, and in WebKit it's whatever reason WebKit gives. If your suite runs in more than one browser, the same failure can show up under three different messages.

Where "maybe frame was detached?" comes from

Playwright doesn't check whether a frame was detached. In the 1.63.0 source, the suffix is added whenever the browser reports that the document request was cancelled: Chromium's canceled flag, Firefox's NS_BINDING_ABORTED, or a WebKit error that contains "cancelled". The "maybe" is literal.

Our runs show how loose that guess is:

  • In Chromium, the suffix appeared when the test timed out and when the page was closed. Both times the whole page went away, not one frame.
  • In Chromium, the iframe that really was removed failed with plain net::ERR_ABORTED at …, with no suffix.
  • In Firefox, a 204 response got the suffix, and no frame was detached at all.

So read the suffix as "the load was cancelled", not as a diagnosis. The message without the suffix, net::ERR_ABORTED at <url>, at least names the URL that was being loaded. In our Chromium runs that was the URL of the navigation that lost, not the one that replaced it.

Seven causes, one at a time

1. The test ran out of time during goto

This is the case people meet most, because it shows up on CI when a page is slow:

Test timeout of 5000ms exceeded.
 
Error: page.goto: net::ERR_ABORTED; maybe frame was detached?
Call log:
  - navigating to "http://localhost:4323/slow?ms=10000", waiting until "load"
 
 
  25 | test("the test times out during goto", async ({ page }) => {
  26 |   test.setTimeout(5_000);
> 27 |   await page.goto("/slow?ms=10000");
     |              ^
  28 | });

There are two errors, and the first one is the cause. When the test ran out of time, Playwright closed the page, and the goto that was still waiting failed with net::ERR_ABORTED. Firefox and WebKit said it more plainly: page.goto: Test timeout of 5000ms exceeded. If you only search for the second line, you'll look for a network problem that isn't there. Our Playwright timeout errors guide covers which timeout fired and what to change.

2. The page was closed while it was loading

Error: page.goto: net::ERR_ABORTED; maybe frame was detached?
Call log:
  - navigating to "http://localhost:4323/slow?ms=3000", waiting until "load"
 
 
  30 | test("the page is closed during goto", async ({ page }) => {
  31 |   setTimeout(() => page.close(), 1000);
> 32 |   await page.goto("/slow?ms=3000");
     |              ^
  33 | });

Chromium printed exactly the same message as the timeout. Firefox and WebKit printed Target page, context or browser has been closed, which is closer to the truth. In real suites, the close() is in a fixture, a helper, or an afterEach, or it's a browser.close() in a script that didn't await the navigation. Target page, context or browser has been closed shows how to find what closed it.

3. A second navigation started before the first finished

Error: page.goto: net::ERR_ABORTED at http://localhost:4323/slow?ms=3000
Call log:
  - navigating to "http://localhost:4323/slow?ms=3000", waiting until "load"
 
 
  15 |
  16 | test("a second goto before the first finished", async ({ page }) => {
> 17 |   page.goto("/slow?ms=3000");
     |        ^
  18 |   await page.goto("/landing");
  19 | });

A page can load one document at a time. When the second goto started, the browser dropped the first, and the first goto failed. The error points at the navigation that lost, which helps: the bug is on the line above whatever ran next. Firefox named the cause outright: Navigation to "…/slow?ms=3000" is interrupted by another navigation to "…/landing". WebKit reported page.goto: Test ended., the message Playwright Test uses for a call that was still running when the test finished.

Putting both navigations in one Promise.all didn't help. Chromium and Firefox aborted the first, and WebKit never settled: the test hung until the 30-second test timeout. In real code the second navigation is rarely a second goto. Look for a click that navigates while a goto is still running, a goto without await, or a forEach(async ...) that opens several pages in one tab.

The redirect test passed in all three browsers, though. When the page itself sent the browser on (location.href = "/landing" while loading), page.goto("/hops") followed it and resolved on the landing page. A redirect started by the page isn't an abort; two navigations started by your test are.

4. The response had no page in it: 204 No Content

Error: page.goto: net::ERR_ABORTED at http://localhost:4323/ping
Call log:
  - navigating to "http://localhost:4323/ping", waiting until "load"
 
 
   6 |
   7 | test("goto a 204 No Content", async ({ page }) => {
>  8 |   await page.goto("/ping");
     |              ^
   9 | });

A 204 No Content response tells the browser there is nothing to show, so the browser stays where it was and the navigation counts as aborted. WebKit was the clearest: page.goto: Aborted: 204 No Content. This happens when a test opens a health check, a tracking pixel or an API endpoint with page.goto. These aren't pages, and they're better checked with the request API (see the fixes below).

5. A route aborted the document

Error: page.goto: net::ERR_FAILED at http://localhost:4323/landing

route.abort() without an argument fails the request with net::ERR_FAILED. With route.abort("aborted") it fails with net::ERR_ABORTED. In both cases the document itself was aborted, so goto failed. WebKit reported Blocked by Web Inspector for both. This usually comes from a "block everything we don't need" route whose pattern also matches the page. A **/* pattern that aborts unwanted requests aborts the document too, unless the handler checks the resource type.

6. An iframe was removed while it was navigating

Error: frame.goto: net::ERR_ABORTED at http://localhost:4323/slow?ms=3000
Call log:
  - navigating to "http://localhost:4323/slow?ms=3000", waiting until "load"
 
 
  47 |   const frame = page.frame({ name: "pay" })!;
  48 |   setTimeout(() => page.evaluate(() => document.querySelector("iframe")!.remove()), 1000);
> 49 |   await frame.goto("/slow?ms=3000");
     |               ^
  50 | });

This is the one case where a frame really was detached, and Chromium's message had no suffix. Firefox (NS_BINDING_ABORTED; maybe frame was detached?) and WebKit (Load request cancelled; maybe frame was detached?) did add it. In apps this is a payment or sign-in iframe that the page re-renders or replaces while the test is still navigating or acting inside it.

7. Not ERR_ABORTED any more: a download

Older answers say a goto to a file download fails with net::ERR_ABORTED. In Playwright 1.63.0, all three browsers said something else:

Error: page.goto: Download is starting
Call log:
  - navigating to "http://localhost:4323/report.csv", waiting until "load"
 
 
  2 |
  3 | test("goto a file download", async ({ page }) => {
> 4 |   await page.goto("/report.csv");
    |              ^
  5 | });

The browser saw Content-Disposition: attachment, started a download, and never loaded a page, so goto had nothing to wait for. Clicking the link that starts the same download didn't fail at all (the last test passed): a click doesn't wait for a page load. If you still see net::ERR_ABORTED for a download, you're probably on an older Playwright version. The fix is the same either way.

How to fix it

Each fix either gives goto a real page to load, or stops using goto for something that isn't a page. tests/fixed.spec.ts:

import { test, expect } from "@playwright/test";
 
test("download: wait for the download event", async ({ page }) => {
  await page.goto("/");
  const downloadPromise = page.waitForEvent("download");
  await page.getByRole("link", { name: "Download report" }).click();
  const download = await downloadPromise;
  expect(download.suggestedFilename()).toBe("report.csv");
});
 
test("download by URL: wait for the event, expect goto to fail", async ({ page }) => {
  const downloadPromise = page.waitForEvent("download");
  await page.goto("/report.csv").catch(() => {});
  const download = await downloadPromise;
  expect(download.suggestedFilename()).toBe("report.csv");
});
 
test("file or 204: check it with the request API", async ({ request }) => {
  const csv = await request.get("/report.csv");
  expect(csv.headers()["content-disposition"]).toContain("report.csv");
  expect(await csv.text()).toContain("15.99");
 
  const ping = await request.get("/ping");
  expect(ping.status()).toBe(204);
});
 
test("one navigation at a time", async ({ page }) => {
  await page.goto("/slow?ms=1000");
  await page.goto("/landing");
  await expect(page.getByRole("heading")).toHaveText("Landing");
});
 
test("block images, not the document", async ({ page }) => {
  await page.route("**/*", (route) =>
    route.request().resourceType() === "image" ? route.abort() : route.continue(),
  );
  await page.goto("/hops");
  await expect(page.getByRole("heading")).toHaveText("Landing");
});
 
test("navigate the iframe, then let the app remove it", async ({ page }) => {
  await page.goto("/frames");
  const frame = page.frame({ name: "pay" })!;
  await frame.goto("/slow?ms=1000");
  await expect(page.frameLocator("iframe").getByRole("heading")).toHaveText("Slow");
  await page.evaluate(() => document.querySelector("iframe")!.remove());
  expect(frame.isDetached()).toBe(true);
});

We ran it four times in Chromium and twice each in Firefox and WebKit. All six tests passed every time:

Running 6 tests using 1 worker
 
  ok 1 [chromium] › tests\fixed.spec.ts:3:5 › download: wait for the download event (279ms)
  ok 2 [chromium] › tests\fixed.spec.ts:11:5 › download by URL: wait for the event, expect goto to fail (117ms)
  ok 3 [chromium] › tests\fixed.spec.ts:18:5 › file or 204: check it with the request API (37ms)
  ok 4 [chromium] › tests\fixed.spec.ts:27:5 › one navigation at a time (1.1s)
  ok 5 [chromium] › tests\fixed.spec.ts:33:5 › block images, not the document (129ms)
  ok 6 [chromium] › tests\fixed.spec.ts:41:5 › navigate the iframe, then let the app remove it (1.2s)
 
  6 passed (4.6s)

What each fix does:

  • Downloads: wait for the download, not a page. Start page.waitForEvent("download") before the click, then check the file. If you only have the URL, the second test shows the pattern: start waiting for the download, let the goto fail (that's expected here, so the .catch is deliberate and scoped to that one line), then await the download. The Playwright downloads docs use the click version.
  • Files, health checks and 204s: use the request API. request.get() returns the status, headers and body without a browser tab, so a 204 is a status you can assert, not an aborted navigation. The request fixture shares the config's baseURL.
  • One navigation at a time. Await each goto before starting the next, and await the navigation a click starts before you call goto again. Never pass two navigations of the same page to Promise.all; in WebKit that hung for the whole test timeout.
  • Routes: abort by resource type, not by "everything". Check route.request().resourceType() and continue the document. The test above blocked the slow image and still loaded the page.
  • Iframes: finish in the frame before the app removes it. Wait until the frame shows what you expect, then do whatever makes the app replace it. Afterwards, frame.isDetached() tells you the old frame object is gone; get a new one with page.frame() or use page.frameLocator(), which looks the iframe up again each time.

For the timeout case, the fix is the slow page, not the error. Until it's fixed, give that one goto a limit of its own, shorter than the test timeout, so the failure names the step:

await page.goto("/slow?ms=10000", { timeout: 3_000 });

All three browsers then failed with the same, clear message, and no ERR_ABORTED:

TimeoutError: page.goto: Timeout 3000ms exceeded.
Call log:
  - navigating to "http://localhost:4323/slow?ms=10000", waiting until "load"

How to find the cause

When the message alone doesn't tell you, ask these in order:

  1. Is there a "Test timeout … exceeded." above it? Then the timeout is the cause and ERR_ABORTED is a side effect. Find out why the page is slow.
  2. Did something close the page? Look in fixtures, hooks and helpers for close(), and in scripts for a browser.close() that can run before the navigation finishes.
  3. Is the URL a page? Open it in a browser. If it downloads a file, returns nothing (204), or is an API endpoint, use the download event or the request API.
  4. Did two navigations overlap? Look for a goto or a navigating click without await, and for Promise.all or forEach(async ...) around navigations. Our Playwright test reviewer flags actions without await.
  5. Does a route or an iframe change hands? Check page.route patterns that can match the document, and iframes the app replaces. A trace (npx playwright test --trace on, then npx playwright show-trace) shows the network requests and frames around the failing step.

Exercise

Change the /hops page so its script waits 2 seconds before location.href = "/landing". Predict whether page.goto("/hops") still fails, passes on "Hops", or passes on "Landing" in each browser, then run it and check. (Hint: what does goto wait for by default, and when does the load event fire?)

Conclusion

net::ERR_ABORTED in page.goto means the browser stopped loading the document, and "maybe frame was detached?" is Playwright's guess at why, added whenever the request was cancelled. Look for what stopped it: a test timeout, a closed page, a second navigation, a route, a removed iframe, or a URL that isn't a page. If your suite runs in several browsers, expect the same cause to show up as NS_BINDING_ABORTED, Aborted: 204 No Content, Test ended. or Target page, context or browser has been closed too.

Sources and further reading

Tools mentioned

PlaywrightUI AutomationOpen source

Links go to each tool’s official site. How we choose and link tools

Revision history

First published.

Spotted a mistake? Report it — corrections land here.

Written and reviewed by

QA Vibes Editorial

Articles are written and reviewed by practicing QA and automation engineers. Every article lists its sources and shows when it was last updated.

Check your own tests

Playwright test reviewer

Paste a test to find fixed waits, missing awaits, and assertions that can never fail.