Skip to content
Rémy Duthu Rémy Duthu
May 13, 2026 · 5 min read

Playwright route handlers fire only on requests they were registered before

Why a page.route() call placed after page.goto() silently misses the request you wanted to intercept, and the registration-order rule that makes network mocks deterministic.

A Playwright test sets up a route handler to mock the /api/me response. The page loads, the test asserts on the rendered username, and the assertion fails: it sees the real backend’s response, not the mock. Add a console.log to the handler — it never fires. The route is correct, the URL pattern matches, the handler exists. It is registered too late.

We see this pattern often enough on Mergify Test Insights that it earned its own slot in our flaky Playwright catalog. The cause is registration order: page.route only intercepts requests that fire after the call. The fix is one rule about where the call goes.

What you see

import { expect, test } from "@playwright/test";

test("shows the user", async ({ page }) => {
  await page.goto("/profile");
  await page.route("**/api/me", (route) =>
    route.fulfill({ json: { id: "u-1", name: "Rémy" } })
  );
  await expect(page.getByText("Rémy")).toBeVisible(); // sometimes fails
});

Most of the time, the assertion passes because the page eventually re-fetches /api/me (a SWR refresh, a focus event, an interval poll) and the second fetch hits the registered route. About 20% of CI runs the page settles on the original response before the refetch fires, and the assertion sees the real backend’s user.

Worse: on a clean local run with the dev server pointing at a stub backend that returns { name: "TestUser" }, the test sees "TestUser" in the DOM, the assertion still fails because it expected "Rémy", and the test author concludes the route is broken. It is not broken. It is registered too late to catch the initial request.

Why route handlers are ordered

Playwright’s page.route() is evaluated synchronously in the test, but the handler only attaches to future requests. Any in-flight request, and any request triggered by page.goto’s response (the initial document load, all linked resources, the JS bundle’s startup fetches), passes through to the real backend. The route only catches requests dispatched after the call returns.

This is documented behavior, not a bug. The reason: route handlers can intercept and modify requests, which is a security-sensitive operation. Playwright requires explicit registration before the request fires so the test author cannot accidentally intercept requests they did not see coming.

The contract is simple. The trap is that page.goto triggers many requests, and “the request you wanted to mock” is almost always one of them.

The naive fix and why it is incomplete

await page.goto("/profile");
await page.waitForTimeout(100);
await page.route("**/api/me", handler);
await expect(page.getByText("Rémy")).toBeVisible();

Wait 100ms after goto to give the page time to settle, then register the route. This works if the page polls /api/me on an interval, because the next poll will hit the route. It does not work if /api/me is fetched once on mount and never again — the assertion never sees the mocked data.

await page.goto("/profile");
await page.reload();
await page.route("**/api/me", handler);

Reload after registering. Some of the time the page makes the fetch on the second load. The first load already hit the real backend, polluted any client-side cache, and the test is now checking that the cache still shows the wrong data on reload. Brittle.

The fix that holds

Register the route before the navigation:

test("shows the user", async ({ page }) => {
  await page.route("**/api/me", (route) =>
    route.fulfill({ json: { id: "u-1", name: "Rémy" } })
  );
  await page.goto("/profile");
  await expect(page.getByText("Rémy")).toBeVisible();
});

Now the route handler is in place when page.goto triggers the initial fetch. Every request to /api/me, including the one fired by the page mount, gets intercepted. The test is deterministic.

The general rule: if you want to mock a request triggered by page.goto, register the route before page.goto. If you want to mock a request triggered by a click, register the route before the click. Routes are forward-looking only.

When you need different mocks for different navigations

For a test that navigates to multiple pages with different mock requirements, register the route once per page or scope it to the URL pattern:

await page.route("**/api/users/**", handler);  // matches every user URL
await page.goto("/users/u-1");
await page.goto("/users/u-2");

Or replace the handler between navigations:

await page.route("**/api/me", meV1Handler);
await page.goto("/profile");

await page.unroute("**/api/me");
await page.route("**/api/me", meV2Handler);
await page.reload();

unroute removes the previous handler. Without it, both handlers fire, and the order Playwright dispatches them is not guaranteed to match the order you registered them in.

When the mock has to vary per request

Use a counter inside the handler closure, not multiple registrations:

let callCount = 0;
await page.route("**/api/me", (route) => {
  callCount++;
  route.fulfill({
    json: callCount === 1 ? { name: "Rémy" } : { name: "Rémy Updated" },
  });
});

This is more honest than re-registering: the test reads top to bottom and the handler logic is the dispatch.

How Mergify catches this before you ship

Route-handler-too-late failures are intermittent because the page re-fetches in some runs and not others. They are easy to dismiss as “flaky test, retry it.” On retry, the run that re-fetches is the run that passes, so the test author concludes retry “fixes” it.

Test Insights groups failures whose only signature is expected "Rémy" but got <real backend value> into a single bucket and tags them as mock-not-applied failures. The dashboard surfaces the test alongside the order of page.route and page.goto calls in the test body, so the registration-order issue is visible without rerunning anything.

Quarantine kicks in once the pattern is clear, so the merge queue keeps moving while you reorder the calls.

Want to find your registration-order races? Point Mergify at your suite. Works with Playwright’s built-in JUnit reporter or any JUnit-compatible output.

More patterns like this

Route handlers registered too late are one of the eight patterns in the flaky-tests-in-Playwright guide. The others are variants of the same theme: tests that race against the page in ways the framework does not auto-cover. Auto-wait racing element re-renders, networkidle that never settles in SPAs, waitForResponse subscribed too late, locator strict-mode violations after a UI change. Same shape, different surface.

The good news: each one is a registration-order or locator-strategy decision, not a test rewrite.

Test Insights

Tired of flaky tests blocking your pipeline?

Test Insights detects flaky tests, quarantines them automatically, and tracks test health across your suite.

Try Test Insights

Recommended posts

Testing

regex101: A Practical Guide to the Regex Tester Engineers Actually Use

May 13, 2026 · 6 min read

regex101: A Practical Guide to the Regex Tester Engineers Actually Use

regex101 is the online regex tester most engineers reach for. Live matching, explained groups, multi-flavor support, and a permalink for every pattern. Full guide to the features that matter and a short cheat sheet.

Julien Danjou Julien Danjou
Testing

vi.mock hoists. Your closure variables do not.

May 11, 2026 · 5 min read

vi.mock hoists. Your closure variables do not.

Why a Vitest mock factory that references a stub object returns undefined, the vi.hoisted() escape hatch, and the import-order rule that keeps your mocks predictable.

Rémy Duthu Rémy Duthu
Testing

pytest-xdist makes the suite faster and the flakes weirder

May 9, 2026 · 6 min read

pytest-xdist makes the suite faster and the flakes weirder

Why a test that always passes alone fails on `pytest -n auto`, the fixture-scope rule that prevents most worker races, and the worker_id pattern for genuinely shared resources.

Rémy Duthu Rémy Duthu