Playwright waitForURL pauses a test until the main frame is on a URL that matches a pattern you give it. It's the right wait after a login redirect, a form submit, or a client-side route change in a single-page app. It's one tool in a bigger kit; the Playwright wait guide shows which wait fits which situation.
This post covers the signature, all four ways to match a URL, the waitUntil and timeout options, whether you need to start waiting before the click, SPA routes, toHaveURL() as the assertion alternative, and how to fix "Timeout exceeded" errors. Every example is JavaScript and was run on Playwright 1.62.
Quick answer: await page.waitForURL('**/dashboard') after the action that navigates. It resolves immediately if the page is already on a matching URL, so calling it after the click is safe. If you only want to check the URL, use await expect(page).toHaveURL(/dashboard/).
page.waitForURL() Syntax
Playwright waitForURL is a page method that pauses a test until the main frame's URL matches a string, glob, RegExp or predicate, then waits for the waitUntil load state (load by default). Use it after clicks, form submits and redirects that change the URL.
await page.waitForURL(url, options); // url: string (exact or glob) | RegExp | (url: URL) => boolean // options: { timeout?: number, // waitUntil?: 'load' | 'domcontentloaded' | 'networkidle' | 'commit' }
It returns a promise that resolves with no value once the URL matches and the waitUntil state is reached. Under the hood it works in two steps: if the current URL already matches, it just waits for the load state; otherwise it waits for a navigation to a matching URL. Recent versions also accept a URLPattern object, but the string, RegExp and function forms below cover everyday use.
Matching URLs: String, Glob, RegExp and Predicate
All five tests below log in on the-internet's demo login page, then wait for /secure in a different way:
const { test, expect } = require('@playwright/test'); test.beforeEach(async ({ page }) => { await page.goto('https://the-internet.herokuapp.com/login'); await page.getByLabel('Username').fill('tomsmith'); await page.getByLabel('Password').fill('SuperSecretPassword!'); await page.getByRole('button', { name: 'Login' }).click(); }); test('glob pattern', async ({ page }) => { await page.waitForURL('**/secure'); await expect(page.getByRole('heading', { name: 'Secure Area', exact: true })).toBeVisible(); }); test('exact string (full URL, no wildcards)', async ({ page }) => { await page.waitForURL('https://the-internet.herokuapp.com/secure'); }); test('regular expression', async ({ page }) => { await page.waitForURL(/\/secure$/); }); test('predicate function', async ({ page }) => { await page.waitForURL((url) => url.pathname === '/secure'); }); test('with waitUntil and timeout options', async ({ page }) => { await page.waitForURL('**/secure', { waitUntil: 'domcontentloaded', timeout: 10000 }); await expect(page.locator('#flash')).toContainText('You logged into a secure area!'); });
| Form | Example | Matches |
|---|---|---|
| Glob string | '**/secure' | Any URL ending in /secure. ** matches any characters including /; * stops at /. |
| Exact string | 'https://the-internet.herokuapp.com/secure' | Only that exact URL. A string without wildcards must match exactly. |
| RegExp | /\/secure$/ | Anything the regex matches. Best when query strings or IDs vary. |
| Predicate | (url) => url.pathname === '/secure' | Your own logic on a URL object: path, query params, host. |
One trap with exact strings: page.waitForURL('/login') only works if you set baseURL in your config, because then Playwright resolves the relative path against it. Without baseURL it times out even while the page sits on https://the-internet.herokuapp.com/login. Use '**/login' when in doubt.
waitUntil and timeout Options
waitUntil(default'load'): what must happen after the URL matches.'domcontentloaded'is faster when you only need the HTML;'commit'resolves as soon as the response arrives and the document starts loading;'networkidle'is discouraged by Playwright, because it waits for 500 ms with no network traffic, which some pages never reach.timeout: in milliseconds. In Playwright Test the default comes fromnavigationTimeout, which is0(no limit) unless you set it, so the 30-second test timeout is what usually stops it. Pass{ timeout: 10000 }to fail faster with a clearer message.
Flaky after a redirect? The usual culprit isn't the app. It's a wait that resolved on the wrong page or a sleep that guessed the timing. Your tests aren't flaky, your waits are: see the fix →
Click, Then waitForURL: Is There a Race?
For waitForResponse and waitForEvent you must start waiting before the click, because they only see events that happen after you call them. waitForURL is different: it first checks the current URL. If the navigation finished while the click was still returning, the URL already matches and waitForURL resolves at once. On the demo login page, page.url() is usually already /secure by the time click() returns, so the following waitForURL('**/secure') resolves at once.
So both of these are correct:
// 1. Wait after the click (simplest, and safe) await page.getByRole('link', { name: 'Get started' }).click(); await page.waitForURL('**/docs/intro'); // 2. Promise first (also fine; same result) const urlPromise = page.waitForURL('**/docs/intro'); // no await yet await page.getByRole('link', { name: 'Get started' }).click(); await urlPromise;
The real race is a pattern that already matches before the navigation. Both styles fail the same way here. Enter a wrong password on the-internet's login page and the app redirects you back to /login; a waitForURL('**/login') resolves instantly and proves nothing. The same happens with a broad pattern such as '**/docs/**' when you're already inside the docs. The fix:
- Make the pattern match only the destination:
'**/docs/intro', not'**/docs/**'. - Follow the wait with an assertion on the new page's content, such as its heading.
waitForURL with SPA Route Changes
Single-page apps change the URL with the History API instead of loading a new document. waitForURL still works, because Playwright treats History API changes and hash changes as navigations. Both tests below change the URL without a full page load:
const { test, expect } = require('@playwright/test'); test('SPA route change on playwright.dev', async ({ page }) => { await page.goto('https://playwright.dev/'); await page.getByRole('link', { name: 'Get started' }).click(); // Client-side route change (no full page load) still counts await page.waitForURL('**/docs/intro'); await expect(page.getByRole('heading', { level: 1 })).toHaveText('Installation'); }); test('hash route change in TodoMVC', async ({ page }) => { await page.goto('https://demo.playwright.dev/todomvc'); const input = page.getByPlaceholder('What needs to be done?'); await input.fill('Buy milk'); await input.press('Enter'); await page.getByRole('link', { name: 'Completed' }).click(); await page.waitForURL('**/#/completed'); await expect(page.getByTestId('todo-item')).toHaveCount(0); }); test('promise-first pattern', async ({ page }) => { await page.goto('https://playwright.dev/'); const urlPromise = page.waitForURL('**/docs/intro'); // no await yet await page.getByRole('link', { name: 'Get started' }).click(); await urlPromise; }); test('assertion alternative: toHaveURL', async ({ page }) => { await page.goto('https://playwright.dev/'); await page.getByRole('link', { name: 'Get started' }).click(); await expect(page).toHaveURL(/\/docs\/intro$/); });
The first test clicks "Get started" on playwright.dev, a client-side route change to /docs/intro. The second clicks the "Completed" filter in TodoMVC, which only changes the hash to #/completed. In both cases the URL is the cheap check, and the assertion on content is what confirms the new view actually rendered.
waitForURL vs expect(page).toHaveURL()
page.waitForURL() | expect(page).toHaveURL() | |
|---|---|---|
| Purpose | Wait, then continue | Wait and verify |
| Default limit | Navigation timeout (test timeout by default) | Expect timeout, 5 seconds |
| Load state | Also waits for waitUntil (default load) | Only checks the URL |
| Failure message | "Timeout exceeded", plus the pattern | Expected pattern and the URL it actually received |
| Best for | Helpers, setup steps, before acting on the new page | Test steps where the URL is part of what you verify |
In a test, toHaveURL() is usually the better choice because its failure is self-explanatory:
Error: expect(page).toHaveURL(expected) failed Expected pattern: /secure/ Received string: "https://the-internet.herokuapp.com/login" Timeout: 1000ms
Fixing "Timeout Exceeded" in waitForURL
When the URL never matches, you get this (captured on 1.62 with a 1.5-second timeout):
page.waitForURL: Timeout 1500ms exceeded. =========================== logs =========================== waiting for navigation to "**/secure" until "load" ============================================================
Work through these causes in order:
- The navigation didn't happen. The click missed, a validation error blocked the form, or the login failed. Check the trace or a screenshot. How to debug Playwright tests shows how.
- The pattern is wrong. Exact string with a trailing slash, a query string you didn't expect, or a relative path without
baseURL. Logpage.url()and compare, or switch to a RegExp. - The URL matched but the load state didn't arrive. The log says
until "load". A slow third-party script can hold upload; passwaitUntil: 'domcontentloaded'and assert on the content you need. - The navigation opened in a new tab.
target="_blank"links don't change the current page's URL. Wait forpage.waitForEvent('popup')instead, as shown in the waitForLoadState guide.
For other timeout messages, see the Playwright timeout errors guide, and for the full picture of stabilising tests, the flaky tests fix guide.
Frequently Asked Questions
What does page.waitForURL() do in Playwright?
It waits until the main frame is on a URL matching your pattern, then waits for the load state given by waitUntil (load by default). If the page is already on a matching URL, it only waits for that load state and returns.
Should I call waitForURL before or after the click?
After is fine. waitForURL checks the current URL first, so if the navigation already finished it resolves immediately. Starting the promise before the click also works. What matters is that the pattern matches only the destination, not the page you start on.
How do I use a regex with waitForURL?
Pass a RegExp literal: await page.waitForURL(/\/secure$/). Use a regex when IDs or query strings vary. For full control, pass a function that receives a URL object, for example (url) => url.searchParams.get('id') === '5'.
Does waitForURL work with single-page apps?
Yes. Playwright treats History API changes and hash changes as navigations, so waitForURL resolves on client-side route changes such as playwright.dev's Get started link or TodoMVC's #/completed filter.
What is the difference between waitForURL and toHaveURL?
waitForURL waits and continues; it also waits for a load state and uses the navigation timeout. expect(page).toHaveURL() waits and verifies with the 5-second expect timeout, and its error shows the expected pattern and the actual URL. Use toHaveURL in test steps.
Why does waitForURL time out?
Usually the navigation never happened (the click failed or validation blocked it), the pattern doesn't match (a relative path without baseURL, a trailing slash, a query string), the load event never fired, or the link opened in a new tab.
Asim Noaman
Senior QA Automation Engineer & AI Testing Specialist
With years of hands-on experience building test automation frameworks for production applications, Asim specializes in combining traditional QA methodologies with cutting-edge AI tools. He has helped teams adopt Playwright and AI-driven testing workflows to ship faster with fewer bugs.
Playwright + Claude AI Course
Redirects Pass Locally. Then CI Disagrees.
When a navigation test is flaky, the wait is usually checking the wrong thing at the wrong moment. The course teaches you to wait for evidence, not time, in JavaScript, and to use Claude AI and the Playwright MCP Server to track down the waits that break.
- Login flows, redirects and SPA navigation that stay green
- Assertions that explain failures instead of hiding them
- Claude AI + Playwright MCP Server for writing and fixing tests
- 4.5★ on Udemy, 510+ students, 30-day money-back guarantee
Ready now? Enroll on Udemy · Know your waits? Take the practice tests