Navigation & Smart Waiting
Navigate between pages and handle loading states like a pro
Navigation & Smart Waiting
In the previous tutorial, we mastered assertions. Now let's tackle the tricky part of web testing: dealing with pages that load, change, and do things asynchronously.
Web apps are asynchronous. Pages load, APIs respond, content appears dynamically. In older testing tools, this meant sprinkling sleep(3000) everywhere and praying. Playwright handles most of this automatically, but you still need to understand what's happening.
The Golden Rule
"How long should I wait for things?"
Don't add waits unless you have to. Playwright's auto-wait covers 90% of cases. When you do need explicit waits, use event-based waits, not timers.
// ❌ BAD - arbitrary sleep
await page.click('button');
await page.waitForTimeout(3000); // Hoping the page loads in 3 seconds
// ✅ GOOD - wait for actual condition
await page.click('button');
await expect(page.getByText('Success')).toBeVisible();
Navigating to Pages
Basic Navigation
// Go to a URL
await page.goto('https://example.com');
// Relative URLs work if baseURL is set in config
await page.goto('/dashboard');
// With full URL
await page.goto('https://example.com/login?redirect=/dashboard');
Navigation Options
// Wait until no network activity for 500ms
await page.goto('/dashboard', { waitUntil: 'networkidle' });
// Wait until DOM is loaded (faster, but page might not be ready)
await page.goto('/dashboard', { waitUntil: 'domcontentloaded' });
// Wait until load event (default)
await page.goto('/dashboard', { waitUntil: 'load' });
// Custom timeout
await page.goto('/slow-page', { timeout: 60000 }); // 60 seconds
Wait options explained:
| Option | What it means | When to use |
|---|---|---|
load | Window load event fired | Default, works for most pages |
domcontentloaded | DOM is ready, resources still loading | Fast checks, don't need images |
networkidle | No network requests for 500ms | Heavy SPAs, ensure everything loaded |
commit | Response received, before processing | Just need server response |
Navigation via Click
When clicking a link that navigates:
// Simple - just click, Playwright handles it
await page.getByRole('link', { name: 'About' }).click();
// If you need to wait for specific URL
await page.getByRole('link', { name: 'About' }).click();
await expect(page).toHaveURL('/about');
// If click triggers a full page reload and you want to wait
await Promise.all([
page.waitForNavigation(),
page.getByRole('link', { name: 'Logout' }).click(),
]);
Waiting for Load States
waitForLoadState
Wait for the page to reach a specific load state:
// Wait for network to be idle
await page.waitForLoadState('networkidle');
// Wait for DOM to be ready
await page.waitForLoadState('domcontentloaded');
// Wait for all resources
await page.waitForLoadState('load');
Useful after actions that trigger loading but don't navigate:
await page.getByRole('button', { name: 'Load More' }).click();
await page.waitForLoadState('networkidle');
// Now check for new content
waitForURL
Wait for URL to match a pattern:
// Exact URL
await page.waitForURL('https://example.com/dashboard');
// Pattern match
await page.waitForURL('**/dashboard');
await page.waitForURL(/\/dashboard$/);
// With timeout
await page.waitForURL('**/success', { timeout: 10000 });
Great for SPAs where clicking doesn't cause full navigation:
await page.getByRole('link', { name: 'Settings' }).click();
await page.waitForURL('**/settings');
Handling Single Page Applications (SPAs)
"My app doesn't do full page reloads. Now what?"
SPAs are tricky because "navigation" doesn't reload the page. The URL changes, but it's all JavaScript.
The Pattern
// Click the link
await page.getByRole('link', { name: 'Products' }).click();
// Wait for URL to change
await page.waitForURL('**/products');
// Wait for content to appear
await expect(page.getByRole('heading', { name: 'Products' })).toBeVisible();
For React Router / Vue Router / Next.js
test('navigate through SPA', async ({ page }) => {
await page.goto('/');
// Click nav link
await page.getByRole('link', { name: 'Dashboard' }).click();
// Don't use waitForNavigation - it's for full page loads
// Instead, wait for the URL and content
await expect(page).toHaveURL(/dashboard/);
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
});
Lazy-Loaded Content
When content loads asynchronously:
// Trigger the load
await page.getByRole('tab', { name: 'Reviews' }).click();
// Wait for loading indicator to disappear
await expect(page.locator('.loading-spinner')).toBeHidden();
// Or wait for actual content
await expect(page.getByRole('article')).toHaveCount.greaterThan(0);
Waiting for Network
waitForResponse
Wait for a specific API call to complete:
// Wait for any response to this URL pattern
const response = await page.waitForResponse('**/api/users');
console.log(response.status()); // 200
// Wait for specific conditions
const response = await page.waitForResponse(
resp => resp.url().includes('/api/search') && resp.status() === 200
);
const data = await response.json();
Combined with Actions
The most common pattern — do something and wait for API:
// Click and wait for API in parallel
const [response] = await Promise.all([
page.waitForResponse('**/api/save'),
page.getByRole('button', { name: 'Save' }).click(),
]);
// Check the response
expect(response.status()).toBe(200);
const data = await response.json();
expect(data.success).toBe(true);
waitForRequest
Sometimes you need to verify a request was made:
// Wait for request to be sent
const [request] = await Promise.all([
page.waitForRequest('**/api/track'),
page.getByRole('button', { name: 'Buy Now' }).click(),
]);
// Check what was sent
const postData = request.postDataJSON();
expect(postData.item).toBe('SKU-123');
Multiple API Calls
When an action triggers several API calls:
// Wait for all of them
const [userResp, ordersResp] = await Promise.all([
page.waitForResponse('**/api/user'),
page.waitForResponse('**/api/orders'),
page.goto('/dashboard'),
]);
Waiting for Elements
Auto-Wait (Usually Enough)
Playwright automatically waits when you use actions or assertions:
// This auto-waits for the button to be clickable
await page.getByRole('button', { name: 'Submit' }).click();
// This auto-waits for the element to be visible
await expect(page.getByText('Success')).toBeVisible();
waitForSelector (Rarely Needed)
Only use when you need to wait without acting:
// Wait for element to appear
await page.waitForSelector('.notification');
// Wait for element to disappear
await page.waitForSelector('.loading', { state: 'hidden' });
// Wait for element to be removed from DOM
await page.waitForSelector('.modal', { state: 'detached' });
But usually, prefer assertions:
// Better - expresses intent clearly
await expect(page.locator('.notification')).toBeVisible();
await expect(page.locator('.loading')).toBeHidden();
Waiting for Functions
waitForFunction
Wait for arbitrary JavaScript condition:
// Wait for a variable to be set
await page.waitForFunction(() => window.appReady === true);
// Wait for array to have items
await page.waitForFunction(() => {
return document.querySelectorAll('.item').length > 5;
});
// With polling
await page.waitForFunction(
() => document.querySelector('.counter')?.textContent === '10',
{ polling: 100 } // Check every 100ms
);
Passing Arguments
// Pass variables to the function
const expectedCount = 5;
await page.waitForFunction(
count => document.querySelectorAll('.item').length >= count,
expectedCount
);
Timeouts
Default Timeouts
Playwright has several timeout layers:
// playwright.config.ts
export default defineConfig({
timeout: 30000, // Test timeout (30s)
expect: {
timeout: 5000, // Assertion timeout (5s)
},
use: {
actionTimeout: 0, // Action timeout (no limit by default)
navigationTimeout: 30000, // Navigation timeout (30s)
},
});
Per-Action Timeouts
// Override for slow operations
await page.goto('/heavy-page', { timeout: 60000 });
await expect(page.getByText('Loaded')).toBeVisible({ timeout: 30000 });
await page.getByRole('button').click({ timeout: 10000 });
Test-Level Timeout
test('slow test', async ({ page }) => {
test.setTimeout(120000); // 2 minutes for this test
// ... long running test
});
Common Patterns
Wait for Page to Be "Ready"
async function waitForPageReady(page) {
// Wait for loading indicators to disappear
await expect(page.locator('[data-loading="true"]')).toHaveCount(0);
// Wait for main content
await expect(page.getByRole('main')).toBeVisible();
}
test('dashboard loads', async ({ page }) => {
await page.goto('/dashboard');
await waitForPageReady(page);
// Now test...
});
Wait for Animation to Complete
// Wait for CSS transition to finish
await page.locator('.modal').click();
await page.locator('.modal').evaluate(el => {
return new Promise(resolve => {
el.addEventListener('transitionend', resolve, { once: true });
});
});
// Or just wait for final state
await expect(page.locator('.modal')).toHaveCSS('opacity', '1');
Retry on Flaky Conditions
// Using expect with polling
await expect(async () => {
const text = await page.locator('.dynamic-counter').textContent();
expect(parseInt(text)).toBeGreaterThan(10);
}).toPass({ timeout: 10000 });
Anti-Patterns (Don't Do These)
"Why can't I just use waitForTimeout?"
Because future-you will hate present-you. Here's why:
❌ Arbitrary Timeouts
// BAD - flaky and slow
await page.click('button');
await page.waitForTimeout(5000);
❌ Fixed Sleeps for Loading
// BAD - might be too short or too long
await page.goto('/dashboard');
await page.waitForTimeout(3000);
❌ Polling in a Loop
// BAD - reinventing the wheel
for (let i = 0; i < 10; i++) {
if (await page.locator('.item').count() > 0) break;
await page.waitForTimeout(500);
}
✅ Correct Alternatives
// GOOD - wait for the actual thing
await page.click('button');
await expect(page.getByText('Done')).toBeVisible();
// GOOD - wait for network
await page.goto('/dashboard');
await page.waitForLoadState('networkidle');
// GOOD - built-in retry
await expect(page.locator('.item')).toHaveCount.greaterThan(0);
Debugging Wait Issues
When tests timeout:
- Check what's happening — Use
--headedto watch - Add traces — See the state at each step
- Use pause() — Interactive debugging
test('debug waits', async ({ page }) => {
await page.goto('/dashboard');
// See what's on the page
await page.pause();
// Or screenshot
await page.screenshot({ path: 'debug.png' });
await expect(page.getByText('Welcome')).toBeVisible();
});
What's Next?
You now know how to handle the async web like a pro:
goto()withwaitUntiloptions for page loadswaitForURL()for SPA navigationwaitForResponse()andwaitForRequest()for API calls- Playwright auto-waits with actions and assertions
- Avoid
waitForTimeout()— use event-based waits - Set appropriate timeouts in config and per-action
Your tests will grow. Let's learn how to organize them properly with hooks, fixtures, and good structure. Let's go!