A Playwright route handler runs but the page hangs until the test times out — why?
answer
- The request is waiting for someone
- The failure names the wrong thing
- Every branch owes one decision
- A throw skips the settlement
- Settling twice fails loudly instead
basics
~20 sThe handler almost certainly returned without settling the route. Every route owes exactly one call to fulfill, abort, continue or fallback; miss it on any branch, or throw before reaching it, and the request stays pending until an unrelated assertion times out.
solid answer
~40 sPlaywright pauses the intercepted request until the handler settles it with `route.fulfill()`, `route.abort()`, `route.continue()` or `route.fallback()`. A handler that returns early on some branch, or throws before the settle — `await response.json()` on an HTML error page is the classic — leaves the request pending forever, so the dashboard keeps rendering its skeleton and the failure surfaces as an action or assertion timeout naming a locator, not the route. Confirm it by logging at the top of the handler and immediately before each settlement: entry logged, exit missing, culprit found. The fixes are structural — settle on every path, make the settlement the last awaited statement, use `route.fallback()` as the default branch, and wrap risky parsing in `try/catch` that still settles. The mirror-image bug is louder: settling twice throws `Route is already handled!`.
code
typescript · 17 linesimport { test, expect } from '@playwright/test';
test('every branch settles the route', async ({ page }) => {
await page.route('**/api/forecast**', async route => {
const city = new URL(route.request().url()).searchParams.get('city');
if (city !== 'oslo') return route.fallback(); // was: bare `return` — request hung
try {
const response = await route.fetch();
await route.fulfill({ response, json: await response.json() });
} catch {
await route.abort('failed'); // a parse failure must still settle
}
});
await page.goto('/dashboard?city=bergen');
await expect(page.getByTestId('temperature')).toBeVisible();
});go deeper
Remember that a route handler must always finish the request somehow — fulfil, abort, continue or fall back — and that forgetting to do so makes the page wait rather than fail fast.
Explain why the timeout names a locator instead of the handler, and list the ways a handler exits without settling: an early return, a throw before the settle, an un-awaited call.
Demonstrate the diagnosis: log entry and exit, read every path out of the handler, guard the parse, and treat a bare return in a handler the way you would treat a missing return in a reducer.
Own the convention that keeps this class of bug out of the suite — small handlers, a settlement as the last statement, fallback as the default branch — because each hang costs a full timeout of CI time and a misleading report.
## The invariant being broken Every `Route` handed to a route handler owes exactly one settlement: `route.fulfill()`, `route.abort()`, `route.continue()` or `route.fallback()`. Playwright pauses the browser request until one of those happens. If the handler returns without settling, nothing releases the request — the browser waits forever, the component keeps rendering its loading skeleton, and the failure you finally see is an assertion or action timeout that names a locator, not the route. That is why the symptom is so misleading: the stack points at `expect(page.getByTestId('temp'))`, while the cause is a handler three files away that took an early exit. ## The usual causes - **A branch with no settlement.** The handler stubs only requests for one city and `return`s for the rest. The forgotten branch needs `route.fallback()` or `route.continue()`, not a bare `return`. - **A throw before the settle.** `await response.json()` on a non-JSON body throws; the handler dies and the route is left dangling. Playwright surfaces the handler error, but the request still never completes. - **An un-awaited settle.** `route.fulfill(...)` without `await` returns a promise the handler does not wait on; the handler resolves first and the fulfil can race teardown. - **A settle on the wrong object.** Fulfilling a route captured in an outer variable, or from a previous invocation, leaves the current one untouched. - **Waiting inside the handler for something the page can only do once the request completes** — a deadlock the handler creates itself. The mirror-image bug is the loud one: settle twice and Playwright throws `Route is already handled!`. A handler that fulfils and then also continues, or one that fulfils inside a loop over matched requests, hits it immediately. That failure is at least honest about where it lives. ## How to confirm it in a minute 1. Log the request URL at the very top of the handler and again immediately before each settlement. 2. Run the failing test. If the entry log appears and no exit log does, the handler is the culprit and the log tells you which branch it took. 3. Read the handler for every path out — each `return`, each `throw`, each early exit — and confirm each one settles the route. 4. Wrap risky work in `try/catch` and settle in the `catch` too, so a parse failure degrades to a real response instead of a hang. ## Writing handlers that cannot hang - Settle on every path; treat a `return` inside a route handler the way you would treat a missing `return` in a reducer. - Make the last statement the settlement, and `await` it. - Prefer `route.fallback()` as the default branch: "not my request" is a decision, and it keeps any broader handler in the chain working. - Do not decode a body you did not produce without a guard — a forecast API that answers HTML during an outage will throw inside your handler. - Keep handlers small. A handler that computes, retries or awaits page state has more ways to exit than to settle.
- Why does the failure point at a locator rather than at the route handler?Because nothing about a pending request is itself an error. The browser waits, the component keeps rendering its loading state, and the first thing with a deadline is the assertion or action you wrote. The timeout names that locator, so the handler three files away is never mentioned in the stack.
- What does the error 'Route is already handled!' tell you?That the handler settled the same route more than once — fulfilling and then also continuing, or settling inside a loop. A route accepts exactly one outcome, and Playwright treats a second one as a bug rather than silently replacing the first. It is the opposite failure to a hang, and much easier to find.
- How should a handler that parses the fetched body protect itself?Wrap the parse in `try/catch` and settle in the catch as well, typically by aborting or by fulfilling with the untouched response. A forecast API that answers HTML during an outage will throw inside `response.json()`, and without a catch that throw leaves the route dangling and the test hanging.
saying these in an interview costs you the question
- Blames the locator named in the timeout message
- Thinks an unsettled route falls through to the network
- Uses a bare return as the default branch in a handler
- Assumes a thrown handler error releases the request
- Leaves route.fulfill un-awaited inside an async handler
- Raises the test timeout instead of finding the missing settlement