Every meaningful test sequence has the user moving between pages — homepage → product list → product detail → cart → checkout → confirmation. Each step is a navigation event, and each event is a moment when the test can either trust Playwright's built-in waits or accidentally race ahead and fail. This lesson is the navigation toolkit: page.goto, the waitUntil modes, history controls, URL assertions, and page.waitForURL for the SPAs that don't change the URL the second you click.
page.goto() — the entry point
Every test starts with a navigation. page.goto accepts a full URL or one relative to your baseURL:
await page.goto("https://shop.example.com"); // full URL
await page.goto("/products"); // resolves against baseURL
await page.goto("/products?category=electronics"); // query strings workBy default, goto resolves when the load event fires — that's the moment the main resource and most subresources have downloaded. For most apps that's exactly what you want. For SPAs and slow apps, you can ask goto to wait for a different signal:
await page.goto("/products", { waitUntil: "load" }); // default — load event
await page.goto("/products", { waitUntil: "domcontentloaded" }); // earlier — DOM parsed, no subresources
await page.goto("/products", { waitUntil: "networkidle" }); // later — no requests for 500ms
await page.goto("/products", { waitUntil: "commit" }); // earliest — response received, no body yetPick based on what your assertions need. 'load' is the safe default. 'networkidle' sounds reassuring but can be flaky on apps with polling or analytics — the network never goes idle. 'domcontentloaded' is faster when you don't care about images and CSS. You'll rarely override this — Playwright's web-first assertions handle the rest of the timing for you.
Browser history — back, forward, reload
Three thin wrappers over the browser:
await page.goBack(); // history back
await page.goForward(); // history forward
await page.reload(); // refresh the current pageThese return after the navigation completes (same waitUntil semantics as goto). Useful when a flow requires "undo" via the back button, or when you want to test that a refresh restores draft state.
Reading the URL
page.url() returns the current URL as a string — synchronous, no await:
const currentUrl = page.url();
console.log(currentUrl); // https://shop.example.com/products?page=2Most of the time you don't need to read the URL into a variable — assert on it directly:
await expect(page).toHaveURL("https://shop.example.com/dashboard");
await expect(page).toHaveURL(/\/order\/\d+/); // regex for dynamic IDs
await expect(page).toHaveURL(/\?status=open/); // partial match via regextoHaveURL is web-first — it retries until the URL matches or the assertion timeout fires. That retry is exactly what you want after a click that triggers a redirect: the assertion waits for the redirect to land instead of snapshotting too soon.
Navigating after a click — two patterns
When a click triggers a navigation, you have two ways to wait for it.
Option 1 — assert the URL. This is the cleanest pattern for normal redirects:
await page.getByRole("link", { name: "Products" }).click();
await expect(page).toHaveURL(/\/products$/);
await expect(page.getByRole("heading", { name: "Products" })).toBeVisible();The toHaveURL assertion retries while the navigation completes, then your downstream assertions run against the new page. No explicit waitForNavigation needed.
Option 2 — page.waitForURL for SPAs that update history without an immediate URL change. Some single-page apps update the URL via history.pushState after an animation finishes; the click happens, the URL doesn't change for 200ms, your assertion races. Wrap the click in a waitForURL:
await Promise.all([
page.waitForURL(/\/products$/),
page.getByRole("link", { name: "Products" }).click(),
]);The Promise.all arms the waiter before the click, so you don't miss the URL change between the click and the await. This pattern is also the right one for "open in new tab" scenarios — covered in lesson 3 of this chapter.
page.waitForURL() in detail
waitForURL accepts strings (with glob patterns), regex, or full URLs:
await page.waitForURL("**/dashboard");
await page.waitForURL(/\/order\/\d+/);
await page.waitForURL("https://shop.example.com/checkout/success");
// With a custom timeout
await page.waitForURL(/confirmation/, { timeout: 10_000 });You'll mostly use it inside Promise.all blocks alongside a click that triggers async navigation. For ordinary post-click redirects, expect(page).toHaveURL(...) is more readable.
Redirects are followed automatically
When goto('/legacy-products') redirects to /products, Playwright follows the redirect transparently. The final URL is what page.url() returns and what toHaveURL asserts against:
await page.goto("/legacy-products");
await expect(page).toHaveURL(/\/products$/); // assert the final URL after redirectIf you specifically want to test that a redirect happens (e.g., for SEO or auth), this is exactly how — assert on the final URL the request lands on.
A complete e-commerce navigation flow
A typed test that walks the full path from homepage to confirmation:
import { test, expect } from "@playwright/test";
test.describe("E-commerce navigation flow", () => {
test("walks from homepage to order confirmation", async ({ page }) => {
// 1. Homepage
await page.goto("/");
await expect(page).toHaveURL("/");
await expect(page.getByRole("heading", { level: 1 })).toBeVisible();
// 2. Products
await page.getByRole("link", { name: "Shop now" }).click();
await expect(page).toHaveURL(/\/products$/);
// 3. Product detail
await page
.getByTestId("product-card")
.filter({ hasText: "Wireless Headphones" })
.click();
await expect(page).toHaveURL(/\/products\/wireless-headphones$/);
// 4. Add to cart, navigate to cart
await page.getByRole("button", { name: "Add to cart" }).click();
await page.getByRole("link", { name: "View cart" }).click();
await expect(page).toHaveURL(/\/cart$/);
// 5. Checkout
await page.getByRole("button", { name: "Checkout" }).click();
await expect(page).toHaveURL(/\/checkout$/);
// 6. Submit, await navigation to confirmation (URL contains an order ID)
await page.getByRole("button", { name: "Place order" }).click();
await page.waitForURL(/\/orders\/\d+/);
await expect(page.getByText("Thank you for your order")).toBeVisible();
// 7. Use back button — should return to checkout
await page.goBack();
await expect(page).toHaveURL(/\/checkout$/);
});
});Read each step for the navigation primitive in play. Steps 2-5 use toHaveURL for ordinary post-click redirects. Step 6 uses waitForURL because the order ID isn't known up front. Step 7 uses goBack. None of them use waitForTimeout — Playwright handles the timing.
The navigation lifecycle
Step 1 of 5
click triggers navigation
page.getByRole('link', { name: 'Products' }).click() — Playwright runs actionability checks, then dispatches the click. The browser begins fetching the target URL.
Coming from Cypress?
The mappings:
cy.visit('/products')→await page.goto('/products')cy.url().should('include', '/dashboard')→await expect(page).toHaveURL(/dashboard/)cy.go('back')→await page.goBack()cy.reload()→await page.reload()cy.location('pathname')→new URL(page.url()).pathname(or assert withtoHaveURL)
The biggest difference is that Cypress's chained cy.visit and cy.click automatically wait for navigation to settle in series. Playwright is more explicit — goto awaits its own navigation; clicks that trigger navigation are followed by a toHaveURL or waitForURL to make the wait visible. After two or three real flows it locks in.
⚠️ Common mistakes
- Using
waitUntil: 'networkidle'to "be safe." It sounds reassuring but is flaky on real apps — analytics pings, polling, WebSockets, and chat widgets keep the network busy forever. Stick with the default'load'(or'domcontentloaded'for speed). If a specific test needs a quieter network, scope it:await page.waitForLoadState('networkidle', { timeout: 5_000 })after a specific action. - Asserting on the URL without retry.
expect(page.url()).toBe('/dashboard')snapshots the URL once — if the redirect hasn't completed yet, it fails. Always useawait expect(page).toHaveURL(...)so the assertion retries during the navigation. The Locator/Page assertions retry; reading values into variables does not. - Wrapping every click in
Promise.all([waitForURL, click])reflexively. Most clicks that lead to a navigation work fine with a plainawait click(); await expect(page).toHaveURL(...). Only reach for thePromise.allpattern when the click and the URL change race — typically SPAs that animate before pushing state, or "open in new tab" flows.
🎯 Practice task
Author a multi-page navigation spec end to end. 20-25 minutes.
-
With
baseURL: "https://www.saucedemo.com"set, createtests/navigation.spec.ts. Log in viabeforeEachwithstandard_user/secret_sauce. -
Write a single
test("walks the full Sauce Demo flow")that:import { test, expect } from "@playwright/test"; test("walks the full Sauce Demo flow", async ({ page }) => { // After login (in beforeEach), assert we landed on inventory await expect(page).toHaveURL(/inventory/); // Click into a product detail await page.locator(".inventory_item_name").first().click(); await expect(page).toHaveURL(/inventory-item/); // Back to inventory await page.getByRole("button", { name: "Back to products" }).click(); await expect(page).toHaveURL(/inventory.html$/); // Add an item, go to cart await page .locator(".inventory_item") .first() .getByRole("button", { name: "Add to cart" }) .click(); await page.locator(".shopping_cart_link").click(); await expect(page).toHaveURL(/cart/); // Checkout await page.getByRole("button", { name: "Checkout" }).click(); await expect(page).toHaveURL(/checkout-step-one/); // Use the browser back button await page.goBack(); await expect(page).toHaveURL(/cart/); // Reload — assert we're still on the cart with the item still there await page.reload(); await expect(page.locator(".cart_item")).toHaveCount(1); }); -
Run the test across all three browsers. The
goBackandreloadsteps confirm browser state survives both operations. -
Try a
waitForURLpattern. Add an extra step at the end: click "Continue Shopping" and useawait Promise.all([page.waitForURL(/inventory.html/), page.getByRole('button', { name: 'Continue Shopping' }).click()]). Run again. The test still passes — but you've now used the explicit-navigation pattern for the moment when you'd want it. -
Force a wrong-URL assertion. Change one
toHaveURL(/cart/)totoHaveURL(/checkout/). Run. Read the failure message — Playwright shows the actual URL at timeout, the regex it tried, and the time it waited. That clarity is what makes navigation bugs fast to diagnose. -
Stretch: add a parameter to your test that reads the order ID from the post-checkout confirmation URL. After completing checkout, capture
const m = page.url().match(/order\/(\d+)/); const orderId = m?.[1](Sauce Demo doesn't expose order IDs, so simulate against your own app or the demoqa equivalent). The point: capturing dynamic IDs from URLs is a real-world need;page.url()+ a regex match is the canonical pattern.
You now know how to navigate deliberately and assert on every transition. The next lesson is about the waiting half — the auto-waits Playwright runs for you, and the small set of cases where you genuinely need an explicit waitFor.