---
title: "Playwright route handlers fire only on requests they were registered before"
description: "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."
author: remy-duthu
date: 2026-05-13
category: testing
readTimeMinutes: 5
featured: false
product: test-engine
---

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 Engine](/product/test-engine) that it earned its own slot in our [flaky Playwright catalog](/learn/flaky-tests/playwright). 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

```ts
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

```ts
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.

```ts
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:

```ts
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:

```ts
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:

```ts
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:

```ts
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 Engine](/product/test-engine) 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](/discovery). 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](/learn/flaky-tests/playwright). 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.

