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

Playwright "Target page, context or browser has been closed": What Closed It, and How to Fix It

Playwright's "Target page, context or browser has been closed", reproduced on a one-file local app: a closed context, a popup that closes itself, a page kept between tests, and a script that closes the browser too early. Why a missing await in Playwright Test shows "Test ended." instead, why an assertion on a closed page says "Received: undefined", and the fixes for each.

QA Vibes EditorialPublished 12 minTested with Playwright 1.63.0, Chromium 153, Node.js 20.20.2Revision history ↓

Key takeaways

  • The message means the call was made on a page that was already gone. The line it points at is usually fine; look for what closed the page before it.
  • In Playwright Test 1.63.0 a missing await didn't produce this message: the unawaited click failed with "locator.click: Test ended." Scripts that close the browser too early do get it.
  • An assertion on a closed page doesn't say the page closed. toBeVisible() reported "Received: undefined", so check isClosed() when an assertion fails on a popup.
  • Never keep the page fixture in a variable for the next test. Playwright closes it after each test; the next test failed in 5 ms.
  • When the app closes a window on purpose, wait for its close event and go back to the page that opened it.
Contents (9 sections)

Introduction

"Error: locator.click: Target page, context or browser has been closed" means Playwright was asked to do something on a page that no longer exists. The page itself was closed, or the browser context that owned it, or the whole browser. The line the error points at is usually correct. Something earlier, often something you didn't write as a "close", got there first.

This page reproduces the error four ways, shows three messages you may see instead, and gives the fix for each. Everything was run on 13 October 2026 with Playwright 1.63.0, Chromium 153 and Node.js 20.20.2, against a one-file local app, so you can run it yourself.

The app and the spec

The app has a Save button that shows "Saved" half a second after a click, and a Help link that opens a popup. The popup's "Close help" button calls window.close(), as help and payment windows often do. Save it as server.mjs:

// A one-file app: / has a Save button and a Help link that opens a popup;
// the popup's "Close help" button closes its own window.
import http from "node:http";
 
const pages = {
  "/": `<!doctype html><title>Shop</title><h1>Shop</h1>
<a href="/help" target="_blank">Help</a>
<button id="save" onclick="setTimeout(() => (document.getElementById('s').textContent = 'Saved'), 500)">Save</button>
<p id="s" role="status"></p>`,
  "/help": `<!doctype html><title>Help</title><h1>Help</h1>
<button onclick="window.close()">Close help</button><p>Read me</p>`,
};
 
http
  .createServer((req, res) => {
    res.setHeader("content-type", "text/html");
    res.end(pages[new URL(req.url, "http://localhost").pathname] ?? "not found");
  })
  .listen(4322, () => console.log("app on http://localhost:4322"));

The config starts it:

import { defineConfig } from "@playwright/test";
 
export default defineConfig({
  testDir: "./tests",
  reporter: "list",
  use: { baseURL: "http://localhost:4322" },
  webServer: { command: "node server.mjs", url: "http://localhost:4322" },
});

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

import { test, expect, type Page } from "@playwright/test";
 
test("closing the context, then clicking", async ({ page, context }) => {
  await page.goto("/");
  await context.close();
  await page.getByRole("button", { name: "Save" }).click();
});
 
test("the popup closes itself, then a click", async ({ page }) => {
  await page.goto("/");
  const popupPromise = page.waitForEvent("popup");
  await page.getByRole("link", { name: "Help" }).click();
  const help = await popupPromise;
  await help.getByRole("button", { name: "Close help" }).click();
  await help.getByRole("button", { name: "Close help" }).click();
});
 
test("the popup closes itself, then an assertion", async ({ page }) => {
  await page.goto("/");
  const popupPromise = page.waitForEvent("popup");
  await page.getByRole("link", { name: "Help" }).click();
  const help = await popupPromise;
  await help.getByRole("button", { name: "Close help" }).click();
  await expect(help.getByText("Read me")).toBeVisible();
});
 
test.describe("a page kept between tests", () => {
  let shared: Page;
 
  test("first test stores the page", async ({ page }) => {
    shared = page;
    await shared.goto("/");
    await expect(shared.getByRole("heading", { name: "Shop" })).toBeVisible();
  });
 
  test("second test uses it", async () => {
    await shared.getByRole("button", { name: "Save" }).click();
    await expect(shared.getByRole("status")).toHaveText("Saved");
  });
});
 
test("a click without await", async ({ page }) => {
  await page.goto("/");
  page.getByRole("button", { name: "Save" }).click();
});
 
test("the page crashes", async ({ page }) => {
  await page.goto("/");
  await page.goto("chrome://crash").catch(() => {});
  await page.getByRole("button", { name: "Save" }).click();
});

We ran it three times. Every run gave the same results and the same messages:

Running 7 tests using 1 worker
 
  x  1 tests\closed.spec.ts:3:5 › closing the context, then clicking (216ms)
  x  2 tests\closed.spec.ts:9:5 › the popup closes itself, then a click (457ms)
  x  3 tests\closed.spec.ts:18:5 › the popup closes itself, then an assertion (552ms)
  ok 4 tests\closed.spec.ts:30:7 › a page kept between tests › first test stores the page (273ms)
  x  5 tests\closed.spec.ts:36:7 › a page kept between tests › second test uses it (5ms)
  x  6 tests\closed.spec.ts:42:5 › a click without await (272ms)
  x  7 tests\closed.spec.ts:47:5 › the page crashes (4.5s)

Three of the failures are "Target page, context or browser has been closed". The other three show what you get instead when a test ends before its click, when an assertion runs on a closed page, and when the page crashes.

Four ways to close the page under your test

1. Closing the context you were given

Error: locator.click: Target page, context or browser has been closed
 
  4 |   await page.goto("/");
  5 |   await context.close();
> 6 |   await page.getByRole("button", { name: "Save" }).click();
    |                                                    ^
  7 | });

Closing a browser context closes every page in it. Here it's obvious, but in real suites the close() is in a helper, an afterEach, or a "log out as this user" function written for a test that made its own context. Note that the error comes without a call log: Playwright didn't try to find the button, because there was nothing to search.

2. A popup that closes itself

The second click on "Close help" failed, because the first one made the popup call window.close():

Error: locator.click: Target page, context or browser has been closed
 
  13 |   const help = await popupPromise;
  14 |   await help.getByRole("button", { name: "Close help" }).click();
> 15 |   await help.getByRole("button", { name: "Close help" }).click();
     |                                                          ^
  16 | });

The double click is contrived, but the pattern is common: a payment, sign-in or help window that closes after its last step, and a test that keeps acting on it, or reads a confirmation from it, after the app has already closed it. Your test didn't close anything; the page did.

3. Keeping the page fixture for the next test

The first test stored its page in a variable; the second used it and failed after 5 ms:

Error: locator.click: Target page, context or browser has been closed
 
  35 |
  36 |   test("second test uses it", async () => {
> 37 |     await shared.getByRole("button", { name: "Save" }).click();
     |                                                        ^
  38 |     await expect(shared.getByRole("status")).toHaveText("Saved");
  39 |   });

The page fixture belongs to one test. Playwright Test creates a new context and page for every test and closes them when the test ends, so shared pointed at a closed page by the time the second test ran. Running the second test on its own with -g fails differently: the first test never ran, so shared is undefined, and we got TypeError: Cannot read properties of undefined (reading 'getByRole').

4. A script that closes the browser too early

Outside the test runner, the classic cause is a missing await followed by browser.close(). Save this as script.mjs and run it with the app started (node server.mjs):

import { chromium } from "playwright";
 
const browser = await chromium.launch();
const page = await browser.newPage();
await page.goto("http://localhost:4322/");
page.getByRole("button", { name: "Save" }).click();
await browser.close();
locator.click: Target page, context or browser has been closed
Call log:
  - waiting for getByRole('button', { name: 'Save' })

Node printed this as an uncaught exception and the script exited with code 1. The click started, browser.close() ran before it finished, and the click found its browser gone. The same thing happens with forEach(async ...), Promise.all cleanup that races the work, and a finally block that closes the browser while something still runs.

Messages you may see instead

A missing await in a test: "Test ended."

In a script, a missing await gives the message above. In Playwright Test 1.63.0 it didn't:

Error: locator.click: Test ended.
Call log:
  - waiting for getByRole('button', { name: 'Save' })
 
  42 | test("a click without await", async ({ page }) => {
  43 |   await page.goto("/");
> 44 |   page.getByRole("button", { name: "Save" }).click();
     |                                              ^
  45 | });

The test function returned before the click finished, and Playwright reported the click as cut short by the end of the test. A forEach with an async callback did the same in our run: "locator.click: Test ended." So if you're searching for "Target page, context or browser has been closed" because an older answer told you to look for missing awaits, look for "Test ended." in your report too. Our Playwright test reviewer flags an action or web-first assertion without await, and typescript-eslint's no-floating-promises and no-misused-promises rules catch unawaited promises the reviewer's pattern checks can miss, such as an async callback passed to forEach.

An assertion on a closed page: "Received: undefined"

The third test asserted on the popup after it closed itself. It didn't mention a closed page at all:

Error: expect(locator).toBeVisible() failed
 
Locator:  getByText('Read me')
Expected: visible
Received: undefined
 
  22 |   const help = await popupPromise;
  23 |   await help.getByRole("button", { name: "Close help" }).click();
> 24 |   await expect(help.getByText("Read me")).toBeVisible();
     |                                           ^
  25 | });

Actions say the page closed; assertions, in our run, didn't. In a separate check, toHaveText("") on a page we had closed with page.close() failed with Received: serializes to the same string, which reads like a whitespace problem. When an assertion on a popup or second tab fails with a received value that makes no sense, check page.isClosed() before you debug the locator.

A crash: "Target crashed"

Navigating to chrome://crash crashes the tab on purpose. The next click failed with a different message:

Error: locator.click: Target crashed
Call log:
  - waiting for getByRole('button', { name: 'Save' })

A crash isn't a close. Nothing in your code ended the page; the browser process for it died. On CI that usually means the machine ran out of memory or shared memory (/dev/shm in Docker), and the fix is fewer workers or more memory, not a change to the test. If you see "Target crashed", or "Target page, context or browser has been closed" on CI only, with no close() anywhere near, look at the machine first.

How to fix it

Each fix makes sure the page a call uses still exists, and that whatever closes it does so after the work is finished. tests/fixed.spec.ts:

import { test, expect, type Page } from "@playwright/test";
 
test("awaits the click and the result", async ({ page }) => {
  await page.goto("/");
  await page.getByRole("button", { name: "Save" }).click();
  await expect(page.getByRole("status")).toHaveText("Saved");
});
 
test("waits for the popup to close, then goes back to the page", async ({ page }) => {
  await page.goto("/");
  const popupPromise = page.waitForEvent("popup");
  await page.getByRole("link", { name: "Help" }).click();
  const help = await popupPromise;
  await expect(help.getByText("Read me")).toBeVisible();
 
  const closed = help.waitForEvent("close", { timeout: 5_000 });
  await help.getByRole("button", { name: "Close help" }).click();
  await closed;
  expect(help.isClosed()).toBe(true);
 
  await page.getByRole("button", { name: "Save" }).click();
  await expect(page.getByRole("status")).toHaveText("Saved");
});
 
test("a second user gets a context of its own", async ({ page, browser }) => {
  await page.goto("/");
  const other = await browser.newContext();
  try {
    const otherPage = await other.newPage();
    await otherPage.goto("/");
    await expect(otherPage.getByRole("heading", { name: "Shop" })).toBeVisible();
  } finally {
    await other.close();
  }
  await page.getByRole("button", { name: "Save" }).click();
  await expect(page.getByRole("status")).toHaveText("Saved");
});
 
test.describe("one page shared on purpose", () => {
  test.describe.configure({ mode: "serial" });
  let shared: Page;
 
  test.beforeAll(async ({ browser }) => {
    shared = await browser.newPage();
    await shared.goto("/");
  });
 
  test.afterAll(async () => {
    await shared.close();
  });
 
  test("first test saves", async () => {
    await shared.getByRole("button", { name: "Save" }).click();
    await expect(shared.getByRole("status")).toHaveText("Saved");
  });
 
  test("second test still has the page", async () => {
    await expect(shared.getByRole("status")).toHaveText("Saved");
  });
});

We ran it three times; all five tests passed each time:

Running 5 tests using 1 worker
 
  ok 1 tests\fixed.spec.ts:3:5 › awaits the click and the result (1.2s)
  ok 2 tests\fixed.spec.ts:9:5 › waits for the popup to close, then goes back to the page (1.3s)
  ok 3 tests\fixed.spec.ts:25:5 › a second user gets a context of its own (1.3s)
  ok 4 tests\fixed.spec.ts:52:7 › one page shared on purpose › first test saves (932ms)
  ok 5 tests\fixed.spec.ts:57:7 › one page shared on purpose › second test still has the page (14ms)
 
  5 passed (7.9s)

What each fix does:

  • Await every action and assertion. The first test is the boring fix for the missing await: the click, then a check that it did what it should. Without the check, an awaited click on a button that does nothing still passes.
  • Treat a window that closes itself as an event. Start help.waitForEvent("close") before the click that closes it, await it after, and then carry on with the page that opened the popup. The test now says the window is meant to close. Give that wait its own limit: without one, a window that never closed failed in our run with only "Test timeout of 30000ms exceeded." and no line number. With { timeout: 5_000 } it failed after 5 seconds with TimeoutError: page.waitForEvent: Timeout 5000ms exceeded while waiting for event "close", pointing at the wait.
  • Close only what you created. Leave the page and context fixtures to Playwright. If a test needs a second user, make a context with browser.newContext() and close that one, in a finally so it closes even when the test fails. It got the config's baseURL too: otherPage.goto("/") worked.
  • Share a page only on purpose, and own it. If several tests really must run on one page, create it in beforeAll with browser.newPage(), close it in afterAll, and make the group serial so the tests run in order in one worker. Otherwise a test can't depend on another test's page, and that's usually the better design: the page objects vs fixtures article shows how to share setup without sharing the page.

For scripts, await everything and close the browser in a finally, after the last step. script-fixed.mjs printed saved and exited cleanly:

import { chromium } from "playwright";
 
const browser = await chromium.launch();
try {
  const page = await browser.newPage();
  await page.goto("http://localhost:4322/");
  await page.getByRole("button", { name: "Save" }).click();
  await page.getByRole("status").getByText("Saved").waitFor();
  console.log("saved");
} finally {
  await browser.close();
}

How to find what closed it

When the cause isn't obvious, ask three questions in this order:

  1. Did the page close, or crash? "Target crashed" or a CI-only failure with no close() nearby points at the machine. Rerun with fewer workers and look at memory.
  2. Did the app close it? If the page is a popup, a second tab, or a window opened by a payment or sign-in step, read what the step before the failure did. A trace (npx playwright test --trace on, then npx playwright show-trace) lists every action with a snapshot of the page around it, so you can see which step was the last one the page was there for.
  3. Did your code close it? Search the test, its fixtures, hooks and helpers for close(), and for pages or contexts stored in variables outside a test. Then look for missing awaits, including inside forEach and map callbacks.

Exercise

Change the app so "Close help" closes the window only after a 1-second delay. Predict what the fixed popup test does if you remove the await closed; line, then run it and check whether you were right.

If the page is still there but one element was replaced, the error is "Element is not attached to the DOM" instead. The diagnose case files practise the same habit on other failures: read what the run actually did before changing the test.

Conclusion

"Target page, context or browser has been closed" is reported at the call that found the page gone, not at the code that closed it. Find the close: a context you closed yourself, a window the app closed, a fixture page kept past its test, a script that closed the browser before an unawaited step finished, or a crash. In Playwright Test, also look for the messages that share the cause: "Test ended." for a missing await, and an assertion whose received value makes no sense on a page that is already closed.

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.