Navigation in Playwright
Every test starts with a journey. page.goto() is your compass. But navigation is more than typing a URL—it's understanding when a page is truly ready, handling redirects, and asserting you arrived at the right destination.
📖 The Story: The Race Condition Disaster
Meet Alex. He wrote a test that navigated to a dashboard and immediately checked for a welcome message. It passed on his laptop every time. But in CI, it failed randomly—sometimes the message was there, sometimes it wasn't. Alex added page.waitForTimeout(3000) to "fix" it. Now the test passed but took 3 extra seconds. And occasionally, it STILL failed on slow network days.
The real problem? Alex didn't understand navigation lifecycle events. He was checking for content before the page had finished loading. The fix wasn't a timeout—it was using waitUntil: 'networkidle' and proper URL assertions. Three minutes of learning saved three seconds per test run and eliminated every flaky failure.
🎯 Why Navigation Mastery Matters
Eliminate Race Conditions
Understand exactly WHEN a page is ready—no more random sleep calls.
Handle Redirects
SPA redirects, server redirects, and client-side routing all behave differently.
Assert Correctly
Verify the URL, not just the page content, to confirm navigation succeeded.
🧒 Explain Like I'm 10
Imagine ordering food at a restaurant. page.goto() is like placing your order. But just because you ordered doesn't mean the food is on the table yet. The waitUntil option is like telling the waiter: "Let me know when..."
- 'commit' = The kitchen received your order
- 'domcontentloaded' = The table is set
- 'load' = All the food and drinks are on the table
- 'networkidle' = The waiter has stopped coming to your table—everything is settled
🎓 Professional Explanation
Playwright's navigation API is built on top of the Chrome DevTools Protocol (CDP). When you call page.goto(), Playwright sends a Page.navigate command through CDP and then listens for specific lifecycle events on the page's frame. The waitUntil parameter determines which event resolves the navigation promise. Understanding these events is critical because a modern web page loads in stages: HTML parsing → DOM construction → resource loading → JavaScript execution → API calls → rendering.
🧭 page.goto(): The Primary Navigation Method
// Basic navigation (waits for 'load' event by default) await page.goto('https://playwright.dev'); // With baseURL configured: just use relative path await page.goto('/docs/intro'); // Wait only for DOM to be ready (faster) await page.goto('/dashboard', { waitUntil: 'domcontentloaded' }); // Wait until ALL network activity stops (safest for SPAs) await page.goto('/app', { waitUntil: 'networkidle' }); // Fastest: only wait for the initial response header await page.goto('/api/health', { waitUntil: 'commit' });
Which waitUntil to use? Default 'load' works for most pages. Use 'domcontentloaded' when you need speed and will wait for specific elements later. Use 'networkidle' for SPAs that make API calls after initial render.
📋 waitUntil Options Reference
| Option | Resolves When | Speed | Use Case |
|---|---|---|---|
'commit' | First response header received | ⚡ Fastest | Checking if a URL responds at all |
'domcontentloaded' | HTML parsed, DOM ready (images/fonts may not be loaded) | ⚡ Fast | When you'll wait for specific elements next |
'load' | All resources loaded (images, CSS, fonts) | 🕐 Medium | Default — works for most traditional pages |
'networkidle' | No network requests for 500ms | 🐢 Slowest | SPAs with post-render API calls |
🔄 Other Navigation Methods
// Go back in browser history await page.goBack(); // Go forward in browser history await page.goForward(); // Reload the current page await page.reload(); // Reload with waitUntil await page.reload({ waitUntil: 'networkidle' }); // Go back and wait for network idle await page.goBack({ waitUntil: 'networkidle' });
✅ URL Assertions
// Assert exact URL await expect(page).toHaveURL('https://playwright.dev/docs/intro'); // Assert URL contains a substring await expect(page).toHaveURL(/docs/); // Assert page title await expect(page).toHaveTitle(/Playwright/); // Combine: navigate then assert await page.goto('/login'); await page.getByLabel('Email').fill('admin@test.com'); await page.getByRole('button', { name: 'Sign in' }).click(); await expect(page).toHaveURL(/dashboard/);
⚠️ Common Mistakes
Mistake 1: Using waitForTimeout Instead of Proper Wait Strategies
// ❌ Random sleep — slow and flaky await page.goto('/dashboard'); await page.waitForTimeout(3000); await expect(page.getByText('Welcome')).toBeVisible(); // ✅ Proper wait — fast and reliable await page.goto('/dashboard', { waitUntil: 'networkidle' }); await expect(page.getByText('Welcome')).toBeVisible();
Why waitForTimeout is evil: 3 seconds might be too short on slow networks (test fails) or too long on fast ones (wastes time). Auto-retrying assertions adapt to actual conditions.
Mistake 2: Not Handling SPA Navigation
// ❌ In an SPA, clicking a link doesn't trigger a full page load await page.getByRole('link', { name: 'Settings' }).click(); await expect(page).toHaveURL(/settings/); // May fail! // ✅ Wait for the URL change after SPA navigation await page.getByRole('link', { name: 'Settings' }).click(); await page.waitForURL(/settings/); await expect(page.getByRole('heading', { name: 'Settings' })).toBeVisible();
✅ Best Practices
- Always use
baseURLin config — never hardcode full URLs in tests. - Use
'load'by default — switch to'domcontentloaded'or'networkidle'only when needed. - Never use
waitForTimeout— use auto-retrying assertions orwaitForURL. - Assert URLs after navigation — confirm you landed at the right page.
- Use
waitForURLfor SPA client-side navigation. - Handle redirects —
page.goto()follows redirects automatically; assert the final URL.
🏗️ Senior Engineer Deep Dive
Navigation and the Frame Tree
Every page has a main frame and potentially multiple sub-frames (iframes). page.goto() navigates the main frame. For iframes, you must use frame.goto(). This distinction matters because a navigation in the main frame doesn't affect sub-frame state, and vice versa. In Playwright, page is essentially a proxy for page.mainFrame().
networkidle: The 500ms Heuristic
'networkidle' resolves when there are no more than 0 network connections for at least 500ms. This is a heuristic, not a guarantee. If your app has long-polling or WebSocket connections, 'networkidle' may resolve too early or never resolve at all. In those cases, use waitForSelector or specific element assertions instead.
💼 Interview Questions
page.goto() return?Show answer
Response object or null if the navigation fails. The Response contains status code, headers, and body. Example: const response = await page.goto(url); console.log(response.status());'load' and 'domcontentloaded' in waitUntil?Show answer
'domcontentloaded' fires when the HTML is fully parsed and the DOM tree is built—CSS, images, and fonts may still be loading. 'load' fires when ALL resources (images, CSS, fonts, scripts) have finished loading. 'domcontentloaded' is faster; 'load' is more complete.'networkidle' sometimes hang indefinitely?Show answer
'domcontentloaded' or 'load' and wait for specific elements instead.🏋️ Practical Exercises
Navigation & Assertions
- Navigate to
https://playwright.devusingpage.goto(). - Assert the page URL contains
'playwright'. - Assert the page title contains
'Playwright'. - Click the "Get started" link.
- Use
waitForURLto confirm navigation to the docs page. - Go back using
goBack()and assert you're on the homepage.
SPA Navigation Testing
- Navigate to
https://demo.playwright.dev/todomvc. - Add a todo item.
- Use
page.reload()and verify the todo persists (it won't — this app uses memory). - Navigate away using
page.goto('https://example.com'). - Use
goBack()and verify you're back at TodoMVC. - Compare
'domcontentloaded'vs'load'timing usingconsole.time().
🧩 Mini Project
🎯 Project: Navigation Test Suite
- Create
tests/navigation.spec.ts. - Write a test that navigates to 3 different pages on
playwright.devand asserts each URL. - Write a test that uses
goBack()andgoForward()and verifies browser history works. - Write a test that uses
reload()and verifies the page state resets. - Test all 4
waitUntiloptions and document the timing differences.
🧠 Quiz
❓ FAQ
Show answer
page.goto('file:///path/to/file.html') works. This is useful for testing static HTML files. Make sure the path is absolute.Show answer
navigationTimeout in config or page.goto(url, { timeout: 60000 })). Catch it with try/catch or let the test fail naturally.Show answer
const [newPage] = await Promise.all([page.context().waitForEvent('page'), page.click('a[target=_blank]')]). This waits for the new tab to open and gives you a Page object to interact with.📌 Summary
🎯 Key Takeaways
- page.goto() is the primary navigation method — default waitUntil is
'load'. - 4 waitUntil options: commit → domcontentloaded → load → networkidle (fastest to slowest).
- Never use waitForTimeout — use auto-retrying assertions or waitForURL.
- Assert URLs after navigation with
expect(page).toHaveURL(). - Use waitForURL for SPA navigation — click doesn't trigger full page loads.
- goBack/goForward/reload mirror browser history controls.
- networkidle can hang with WebSockets or long-polling — use element assertions instead.
