page.goto() waits for the page to load. For anything beyond that — buttons that redirect, URL changes after form submit — there are waitForURL and load state options.
goto() and load states
page.goto(url) navigates and waits for the load event by default — meaning all resources (scripts, styles, images) have loaded. For most tests this is the right default. For SPAs that load data after mount, load may complete before the data is visible — but that's fine because Playwright's locators wait for elements to appear anyway.
If load takes too long (heavy page with images/iframes), use waitUntil: 'domcontentloaded' to wait only for the HTML to parse. Or waitUntil: 'networkidle' if you need to wait for all async requests to complete (use sparingly — it's slow).
ts
// Звичайний перехід — чекає load eventawait page.goto('/orders')
// Тільки HTML — без зображень і скриптівawait page.goto('/orders', { waitUntil: 'domcontentloaded' })
// Чекає поки мережа заспокоїться (немає запитів 500ms)await page.goto('/dashboard', { waitUntil: 'networkidle' })
// Одразу — не чекає нічого (рідко потрібно)await page.goto('/orders', { waitUntil: 'commit' })
Waiting for URL changes
After a click that triggers redirect, call waitForURL() to wait for the new URL before asserting page content
When a button click triggers a redirect, Playwright doesn't automatically wait for the navigation to complete before the next line runs. Use waitForURL to assert you've arrived at the expected URL — or to wait for the redirect before doing anything else.
I use waitForURL after form submissions and login flows — it's the most readable way to assert a successful redirect.
ts
test('login redirects to dashboard', async ({ page }) => {
await page.goto('/login')
await page.getByLabel('Email').fill('admin@example.com')
await page.getByLabel('Password').fill(process.env.TEST_PASSWORD!)
await page.getByRole('button', { name: 'Sign in' }).click()
// Чекаємо редиректу на dashboardawait page.waitForURL('/dashboard')
// Або з glob патерномawait page.waitForURL('**/dashboard')
})
test('order submission redirects to confirmation', async ({ page }) => {
await page.goto('/orders/new')
await page.getByLabel('Item').fill('Laptop')
await page.getByRole('button', { name: 'Submit' }).click()
// URL може бути /orders/123/confirmationawait page.waitForURL(//orders/d+/confirmation/)awaitexpect(page.getByRole('heading', { name: 'Order confirmed' })).toBeVisible()
})
The hydration trap
This is a subtle bug that trips people up: the page renders a button (SSR), Playwright clicks it immediately, but the React/Vue/Svelte code hasn't hydrated yet so the click handler doesn't exist. The click does nothing. The test fails with a mysterious "element not found" or just the wrong state.
The fix belongs to the app, not the test: disable interactive elements until hydration completes. In tests, you can work around it by waiting for a signal that hydration is done — like a specific element that only appears after client-side JS runs.
ts
// Якщо застосунок показує loader під час гідратаціїtest('dashboard loads after hydration', async ({ page }) => {
await page.goto('/dashboard')
// Чекаємо поки loader зникне — сигнал що JS виконавсяawait page.getByTestId('loading-spinner').waitFor({ state: 'hidden' })
// Тепер безпечно взаємодіятиawait page.getByRole('button', { name: 'Create order' }).click()
})
Browser history navigation
For testing browser back/forward behavior — like "does clicking back restore the filter state" — Playwright has page.goBack() and page.goForward().
ts
test('back button restores order list filter', async ({ page }) => {
await page.goto('/orders')
await page.getByRole('combobox', { name: 'Status' }).selectOption('pending')
// Перейшли до конкретного замовленняawait page.getByRole('row').first().getByRole('link').click()
await page.waitForURL(//orders/d+/)// Повертаємося назадawait page.goBack()
await page.waitForURL('/orders')
// Фільтр має зберегтисяawaitexpect(page.getByRole('combobox', { name: 'Status' }))
.toHaveValue('pending')
})