TechByteByByte

Part 10: Navigation and Pages

Control navigation and page lifecycles across realistic browser workflows.

Navigation loads a new document. A page usually means one tab, while a popup creates another page.

Think of navigation as moving to another room and a popup as opening a second room.

trigger navigation → load document → wait for useful condition → continue

Series: Playwright Zero to Expert | Demo app used throughout this series: saucedemo.com

Every test you’ve written so far has lived on a single page, in a single tab. Real applications aren’t always this simple — a “View Invoice” link might open a PDF in a new tab, a “Sign in with Google” button might open a popup window, and users routinely use the browser’s own back and forward buttons. This part covers how Playwright handles all of that.


goto() and Basic Navigation

await page.goto("/inventory.html"); // relative, using baseURL from Part 6's config
await page.goto("https://www.saucedemo.com/inventory.html"); // fully qualified, always works

goto() is the same request-response loop from Part 0.2 and 0.3, triggered directly from your test — the browser requests the URL, waits for a response, and loads it.

By default, goto() waits until the load event fires (roughly: the page’s core resources have finished loading) before resolving — which connects directly back to Part 0.3’s point about a single page load actually being several separate requests finishing at different times; goto()’s default wait behavior is Playwright’s attempt at a sensible “page is basically ready” signal, though as you’ll see in Part 12, “basically ready” and “fully ready for your test’s next step” aren’t always the same thing.

await page.goBack();
await page.goForward();
await page.reload();

These map directly onto a browser’s own back, forward, and reload buttons — genuinely useful for testing that an application handles browser navigation correctly, not just its own internal links and buttons (a real, common source of bugs — an application that works perfectly when navigated through its own UI, but breaks or shows stale data when a user presses the browser’s back button, is a legitimate class of bug worth testing for deliberately).


Multiple Pages, Tabs, and Popups

Recall Part 6’s architecture: a browser context can hold more than one page. This is exactly the mechanism behind handling multiple tabs — when an application opens a new tab, Playwright doesn’t automatically switch your test’s focus to it; you have to explicitly capture the new page object yourself.

// Set up a listener for a new page BEFORE triggering the action that opens it
const [newPage] = await Promise.all([
  context.waitForEvent("page"),
  page.getByText("Open in new tab").click(),
]);

await newPage.waitForLoadState();
await expect(newPage).toHaveURL(/some-new-url/);

This pattern deserves close attention, because it looks unusual the first time you see it, and the reason it’s written this way is genuinely important — it’s the same principle from Part 8’s file-download example, generalized. If you wrote this sequentially instead —

// WRONG — a common mistake
await page.getByText("Open in new tab").click();
const newPage = await context.waitForEvent("page"); // might already be too late!

— there’s a real risk that the new tab opens and Playwright’s page event fires before your code even reaches the waitForEvent('page') line, meaning you’d have missed the event entirely and the test would hang waiting for something that already happened.

Analogy: The Package Delivery Drone Imagine expecting a critical medication package dropped off by a high-speed delivery drone:

  • Sequential Approach (Wrong): You wait until the drone arrives, drops the box on the porch, and flies away. Only after you get the phone notification do you put on your shoes, open the door, and walk outside. If the notification is delayed, or if a gust of wind blows the package away before you open the door, you’ve missed it.
  • Concurrent Approach (Right): You sit by the window watching the porch (register the listener) at the exact same time that the drone descends to drop the box (trigger the action). You capture the event as it happens, without any gap.

📊 Visual Flowchart: Sequential vs. Concurrent Event Handlers

Here is how sequential coding introduces a race condition window, and how Promise.all closes it:

graph TD
    subgraph SeqWork ["Sequential Code Workflow (Race Condition Window)"]
        S1["Action: click()"] --> S2["Browser opens new tab (Event fires)"]
        S2 --> S3["Time gap (network latency / CPU lag)"]
        S3 --> S4["Register Event Listener: waitForEvent('page')"]
        S4 --> S5["Result: HANGS (Missed the event already)"]
    end

subgraph ConcurWork ["Concurrent Code Workflow (Resilient)"]
        C1["Promise.all()"] --> C2["Register Listener: waitForEvent('page')"]
        C1 --> C3["Action: click()"]
        C2 --> C4["Listen channel ready"]
        C3 --> C5["Event fires"]
        C4 --> C6["Capture tab handle instantly"]
        C5 --> C6
        C6 --> C7["Result: SUCCESS (Continues test)"]
    end

Promise.all(...) runs both operations concurrently — starting to listen for the new page event and triggering the click that causes it — at the same moment, closing that gap. This exact pattern — set up the listener, then trigger the action, together, concurrently — reappears for popups, downloads, and specific network requests throughout the rest of this series, so it’s worth genuinely internalizing now rather than memorizing as a one-off trick.

Popup windows

(opened via JavaScript’s window.open(), common for things like “Sign in with Google” flows) work identically, using the 'popup' event instead:

const [popup] = await Promise.all([
  page.waitForEvent("popup"),
  page.getByText("Sign in with Google").click(),
]);

await popup.getByPlaceholder("Email").fill("test@example.com");

How It Works in a Real Test Run

Navigation can complete at one browser lifecycle milestone while application data is still loading. Register event waits before the action that triggers them, capture the new Page object, and assert the destination’s user-visible readiness rather than adding a sleep.

The reusable race-safe pattern is listener first + trigger action together → receive event object → wait for the specific observable state needed by the test.

Interview Questions

Q: What does goto() actually wait for by default, and why might that not always be sufficient for a test’s next step?

Ans: By default, goto() waits for the page’s load event, roughly meaning the core resources have finished loading. This isn’t always sufficient because a page can finish “loading” in this sense while dynamic content — like data fetched via a separate API call — is still arriving and rendering afterward, meaning the page can look “ready” to goto() while the specific content a test actually needs still isn’t present yet.

Q: Why doesn’t Playwright automatically switch your test’s focus to a new tab when the application opens one?

Ans: Because a browser context can hold multiple pages simultaneously, and Playwright has no way of knowing, on its own, which one your test actually intends to interact with next — some tests might deliberately want to keep working in the original tab while a new one opens in the background. Requiring you to explicitly capture the new page keeps this ambiguity out of Playwright’s default behavior, leaving the decision to the test author.

Q: Why is Promise.all([context.waitForEvent('page'), someClick()]) used instead of simply calling context.waitForEvent('page') after the click?

Ans: Because the new page might open, and its event might fire, before the code even reaches the waitForEvent line if it’s written sequentially after the click — creating a real risk of missing the event entirely and the test hanging indefinitely waiting for something that already happened. Running both concurrently with Promise.all starts listening for the event at the same moment the triggering action happens, closing that timing gap.

Q: What’s the difference between a new tab and a popup, in terms of how Playwright handles them?

Ans: They’re handled almost identically in Playwright, both represented as new Page objects within the same context — the main practical difference is which event you listen for: a new tab typically triggers a 'page' event on the context, while a window opened via JavaScript’s window.open() (like many “Sign in with X” flows) triggers a 'popup' event on the page that opened it.


Exercises — Part 10

Understand: Explain in your own words why writing await someClick(); const newPage = await context.waitForEvent('page'); sequentially is riskier than using Promise.all for the same task.

Simple Practice: Find any real site with a link that opens in a new tab (many sites use target="_blank" for external links). Write a Playwright test that clicks it, captures the new page using the Promise.all pattern, and asserts on something in the new tab’s content or URL.

Real-World Scenario: On SauceDemo, log in, add a product to the cart, navigate to the cart page, then use page.goBack() to return to the inventory page. Assert that the cart badge still correctly shows one item after navigating back. Explain, in a sentence, why this specific check — behavior after using the browser’s back button — is a genuinely realistic thing to test, not just a theoretical edge case.

Challenge: Research (using Playwright’s own documentation) the difference between waitForLoadState('load'), waitForLoadState('domcontentloaded'), and waitForLoadState('networkidle'). Write one sentence for each explaining what it actually waits for, and note which one you’d be most cautious about relying on by default — you’ll get the full reasoning behind this caution in Part 12.


Next: Part 11 — Real-World Web Elements

— forms, dropdowns, dynamic tables, calendars, modals, and the messier, less textbook-clean elements you’ll actually encounter in production applications.

Author
TechByteByByte Editorial Team
Reviewed by
TechByteByByte Admin
Published
Last reviewed