skip to content

In Playwright, if a page.route() and a context.route() handler both match a request, which one runs first?

level: middleimportance: must knowfreq 58%

answer

  1. The order is defined, not arbitrary
  2. Page scope is consulted before context
  3. Registration order is walked backwards
  4. Pattern specificity plays no part
  5. Stepping aside is an explicit call

basics

~10 s

Playwright consults page-level handlers before context-level ones, and within each scope the most recently registered matching handler wins. Earlier handlers run only if the winner steps aside with route.fallback().

solid answer

~40 s

Playwright resolves handlers in a defined order: those registered with `page.route()` are consulted before those registered with `context.route()`, and inside each scope matching handlers run in **reverse registration order**, so the one added last runs first. That is the rule usually quoted as "the last registered route wins", and it is what lets a test override a fixture's stub simply by registering its own afterwards. Only one handler actually serves a request unless it explicitly defers: `route.fallback()` passes the request to the next matching handler, and to the real network if none is left. Because precedence follows registration time rather than pattern specificity, a broad `'**/*'` handler added late will shadow a precise pattern added early, with no warning.

code

typescript · 15 lines
typescript
import { test, expect } from '@playwright/test';

test.beforeEach(async ({ context }) => {
  await context.route('**/v1/forecast*', route =>
    route.fulfill({ json: { tempC: 21 } }));
});

test('shows the storm banner', async ({ page }) => {
  // Page scope beats context scope, so this stub wins.
  await page.route('**/v1/forecast*', route =>
    route.fulfill({ json: { tempC: 4, alert: 'storm' } }));

  await page.goto('/dashboard');
  await expect(page.getByRole('alert')).toContainText('storm');
});

go deeper

for a junior

Know that registering a second handler for the same URL does not double-stub the request. The newer registration takes it, and only one handler serves each request unless it explicitly defers.

for a middle

Say the two-part rule out loud: page scope before context scope, and reverse registration order within each scope. Then show how a test overrides a fixture default without touching the fixture.

for a senior

Precedence is what keeps layered stubs debuggable. Be ready to explain why a late catch-all shadows earlier precise handlers, and how you would spot that when a suite's stubs quietly stop firing.

for a principal

Own the convention for where default stubs live and how tests override them, so precedence is a documented contract rather than an accident of import order across dozens of spec files.

## The rule in two clauses When more than one handler matches a request, Playwright picks a winner by asking two questions in order: 1. **Which scope?** Handlers registered with `page.route()` are consulted before handlers registered with `context.route()`. 2. **Which registration?** Within a scope, matching handlers are consulted in **reverse registration order** — the one added most recently runs first. That is the whole of the rule people quote as "the last registered route wins". Notice what is *not* in it: the specificity of the pattern plays no part at all. A catch-all `'**/*'` registered late outranks a precise `'**/v1/forecast*'` registered early. ## Only one handler serves a request The consultation stops at the first handler that takes the request. A handler "takes" it by resolving the route; it steps aside by calling `route.fallback()`, which passes the request to the next matching handler in precedence order, and on to the real network if none is left. So handlers form a chain, but by default only the head of the chain runs. | Registered (in order) | Scope | Consulted | |---|---|---| | `context.route('**/v1/**')` | context | third | | `page.route('**/v1/forecast*')` | page | second | | `page.route('**/*')` | page | first | In that table the catch-all serves everything unless it calls `route.fallback()`, even though the middle registration is the one that names the endpoint. ## Why this shape is useful Precedence by recency is what makes layered stubs possible without any unregistering: - A fixture installs the suite's default responses on the **context**. - A single test installs its own stub for one endpoint on the **page**. - The test's stub wins, because page beats context — and it wins regardless of whether the fixture ran before or after, which keeps the override robust. - When the test ends, the page and context are discarded, so nothing has to be undone. The same trick powers debugging: register a logging handler last so it is consulted first, print the URL, then `route.fallback()` into whatever the test actually meant to do. ## Where it bites - **A late catch-all silently shadows everything.** Adding `page.route('**/*', ...)` at the bottom of a helper turns every carefully written stub above it into dead code, and nothing warns you. - **Two registrations for the same endpoint are not additive.** Registering a second handler does not "also" stub the request; it simply takes over. - **Import order becomes behaviour.** If several helpers each register routes, the order in which they are called decides which one answers. That is fine when it is deliberate and awful when it is accidental. - **Specificity intuitions from other tools do not transfer.** Nothing here ranks a narrow pattern above a broad one. ## Making precedence deliberate 1. Decide where the defaults live — one fixture, on the context — and keep every other registration a per-test override on the page. 2. Register broad handlers **first** inside that fixture, so specific handlers registered afterwards are consulted before them. 3. Give catch-all handlers a job that is safe to run first: log and `route.fallback()`, or block a host you never want contacted. 4. Export the URL patterns from a shared module so a default and its override are literally the same string, and a reviewer can see that the override is aimed at the endpoint it means to replace. ## A concrete case A weather dashboard test suite stubs `'**/v1/forecast*'` in a fixture with a mild 21 °C payload. One test needs a storm banner, so it calls `page.route('**/v1/forecast*', ...)` with an alert payload in the test body. The page-scoped handler is consulted first, fulfils the request, and the fixture's handler is never called. Delete the override and the fixture's payload comes back — no unroute, no flag, no shared mutable state. If instead the test had registered its override with `context.route()`, it would still win, but only by virtue of running later, which makes the behaviour depend on fixture ordering rather than on an explicit scope choice.

  • What happens when the winning Playwright route handler calls route.fallback()?
    The request is offered to the next matching handler in precedence order, which is the one registered just before it, and then to the context-level handlers. If none is left, the request goes to the real network. That is how layered stubs compose: a late handler can log or tweak and still let a broad default answer.
  • Does a more specific URL pattern beat a broader one in Playwright routing?
    No. Playwright never ranks patterns by specificity. It only asks which matching handler was registered most recently, with page scope ahead of context scope. A catch-all added last will shadow a precise pattern added first, so ordering is a design decision rather than an implementation detail.

Think of handlers as sticky notes on the same request: the last one stuck on is the one read first, and page notes always sit on top of context notes.

saying these in an interview costs you the question

  • Thinks the most specific URL pattern wins over a broader one.
  • Believes the first registered handler always takes the request.
  • Assumes every matching handler runs for the same request.
  • Thinks context.route overrides a page.route on the same URL.
  • Expects a fixture stub to survive a later duplicate registration.