PRACTICAL GUIDE / Playwright async disposable page route cleanup

Let temporary Playwright routes clean themselves up

Scope page routes with await using, prove cleanup after failures, migrate older helpers safely, and distinguish leaked handlers from service-worker mocks.

By The Testing AcademyUpdated August 4, 202617 min read
All field guides
In this guide6 sections
  1. Treat a route as a resource with a lifetime
  2. Prove that a temporary mock stops at the closing brace
  3. Layer routes without losing track of precedence
  4. Separate a leaked route from look-alike failures
  5. Migrate helpers without making cleanup less visible
  6. Skip scoped routes when another boundary is clearer

What you will learn

  • Treat a route as a resource with a lifetime
  • Prove that a temporary mock stops at the closing brace
  • Layer routes without losing track of precedence
  • Separate a leaked route from look-alike failures

A checkout test passes alone and fails after the discount scenario because the second half of the test still receives yesterday's mocked price. The route handler was meant for one block, but its lifetime was the whole page. When an assertion threw, the cleanup line never ran.

Playwright 1.59 made this ownership visible in TypeScript. page.route() returns a Disposable that works with await using, so the registration is removed when its lexical scope ends. The syntax is small. The engineering value is that cleanup follows control flow instead of relying on every author to remember an unroute call.

Treat a route as a resource with a lifetime

A page route is mutable page state. Once installed, it examines every matching request until it is removed, its times limit is exhausted, or the page closes. The callback often captures test data, counters, credentials, or a feature decision. Leaving it registered can affect a later step on the same page even when the original scenario has finished.

Older test code commonly looks like this:

TypeScript
await page.route('**/api/price', route => {
  return route.fulfill({
    json: { amount: 499, discountApplied: true },
  });
});

await page.getByRole('button', { name: 'Apply coupon' }).click();
await expect(page.getByText('₹499')).toBeVisible();

await page.unroute('**/api/price');

The happy path removes the route. A timeout in the locator assertion skips page.unroute(), as does an exception thrown by the click. A shared page fixture, a multi-phase test, or a helper that catches the failure can then carry the handler farther than intended.

Playwright now lets the declaration express ownership:

TypeScript
{
  await using priceRoute = await page.route(
    '**/api/price',
    route => route.fulfill({
      json: { amount: 499, discountApplied: true },
    })
  );

  await page.getByRole('button', { name: 'Apply coupon' }).click();
  await expect(page.getByText('₹499')).toBeVisible();
}
// priceRoute has been removed here.

await using is JavaScript explicit resource management syntax. At block exit, the runtime invokes asynchronous disposal for the registered resource and waits for it. The exit can be normal, an early return, or an exception. That is the difference from placing page.unroute() after the final assertion.

Use await using, not plain using, for these Playwright resources. Cleanup is asynchronous. The declaration must appear in an async context or another context that permits await. Playwright Test callbacks are async in ordinary TypeScript tests, so they are a natural fit.

The variable name matters to readers even when code never references it again. priceRoute or registration says what will be removed. Naming the Disposable route while also naming the callback parameter route makes review harder:

TypeScript
await using registration = await page.route(
  '**/api/price',
  async interceptedRoute => {
    await interceptedRoute.continue();
  }
);

The Disposable represents the registration, not a single intercepted network request. The callback parameter is a Route for one match. Confusing those two lifetimes leads to bad cleanup assumptions.

Disposal removes that registered behavior. It does not undo a response already fulfilled, rewind the page, clear application state, restore HTTP cache semantics, or resolve a handler that forgot to continue, fulfill, abort, or fall back. Keep every intercepted request action complete inside the callback.

Nested scopes dispose in reverse declaration order. This aligns with Playwright's route precedence, where the most recently registered matching page route gets the first opportunity to handle a request. A temporary inner override can disappear and reveal the outer route again without reconstructing it.

The feature requires compatible layers. Playwright 1.59 documents async disposable support for page routes. The TypeScript parser also needs explicit resource management support. If an editor accepts the code but CI fails before the test starts, compare runtime packages and TypeScript versions instead of debugging network behavior.

Prove that a temporary mock stops at the closing brace

This complete test creates an HTTP application, installs a route for one user action, then makes the same request after the block. The first response is mocked. The second comes from the real local server. Handler counts and source labels prove both paths.

TypeScript
import { createServer } from 'node:http';
import { once } from 'node:events';
import { test, expect } from '@playwright/test';

test('preview price route ends before the confirmed price', async ({
  page,
}) => {
  const server = createServer((request, response) => {
    if (request.url === '/') {
      response.writeHead(200, { 'content-type': 'text/html' });
      response.end([
        '<button id="load">Load price</button>',
        '<p id="price"></p>',
        '<p id="source"></p>',
        '<script>',
        'document.querySelector("#load").addEventListener("click", async () => {',
        '  const response = await fetch("/api/price");',
        '  const value = await response.json();',
        '  document.querySelector("#price").textContent = String(value.amount);',
        '  document.querySelector("#source").textContent = value.source;',
        '});',
        '</script>',
      ].join('\n'));
      return;
    }

    if (request.url === '/api/price') {
      response.writeHead(200, {
        'content-type': 'application/json',
      });
      response.end(JSON.stringify({
        amount: 799,
        source: 'upstream',
      }));
      return;
    }

    response.writeHead(404).end();
  });

  server.listen(0, '127.0.0.1');
  await once(server, 'listening');

  const address = server.address();
  if (!address || typeof address === 'string')
    throw new Error('Expected a TCP listener');

  const origin = 'http://127.0.0.1:' + address.port;
  let previewMatches = 0;

  try {
    await page.goto(origin);

    {
      await using previewRoute = await page.route(
        origin + '/api/price',
        async interceptedRoute => {
          previewMatches += 1;
          await interceptedRoute.fulfill({
            status: 200,
            contentType: 'application/json',
            headers: { 'x-test-route': 'preview-price' },
            json: {
              amount: 499,
              source: 'scoped-preview',
            },
          });
        }
      );

      await page.getByRole('button', { name: 'Load price' }).click();
      await expect(page.locator('#price')).toHaveText('499');
      await expect(page.locator('#source')).toHaveText('scoped-preview');
      expect(previewMatches).toBe(1);
    }

    await page.getByRole('button', { name: 'Load price' }).click();
    await expect(page.locator('#price')).toHaveText('799');
    await expect(page.locator('#source')).toHaveText('upstream');
    expect(previewMatches).toBe(1);
  } finally {
    server.close();
    await once(server, 'close');
  }
});

The final counter assertion is critical. If the server coincidentally returned 799 through a changed fixture, the source label still distinguishes it. If another route supplied the upstream-looking payload, the unchanged previewMatches count would direct the investigation to that other layer.

The closing brace is intentionally close to the scenario. A 200-line test with a route declared at the top and disposed near the bottom remains hard to review. Create the narrowest block around the actions that require the behavior. Lexical scope is part of the test specification.

The handler sets a diagnostic response header, but the page uses a body field for its visible assertion. In a real application, avoid adding a field that changes production code behavior. A handler-local counter and a test attachment may be safer than a header or body marker if the application reacts to unknown metadata.

The example closes the HTTP listener in finally because await using owns only the route registration. It does not own arbitrary Node resources created around the test. Different resources can use different cleanup mechanisms without weakening the route scope.

A failed assertion inside the block still triggers route disposal before control leaves the block. It does not rescue the test. The original assertion remains the failure, and cleanup prepares the page for any outer finally logic, fixture teardown, or diagnostic request.

There is a latency cost. Leaving the block waits for asynchronous disposal. Usually that is tiny compared with a navigation, but a suite with many very short nested registrations performs more protocol round trips than one broad route. Choose clarity first, then measure before consolidating scopes.

Layer routes without losing track of precedence

Nested routes are useful when a long scenario has a default stub and one step needs an exceptional response. The newer registration wins while it exists. After disposal, the earlier route handles the next match.

TypeScript
import { test, expect } from '@playwright/test';

test('temporarily overrides the default account flags', async ({
  page,
}) => {
  const counts = {
    defaultHandler: 0,
    suspendedHandler: 0,
  };

  await using defaultFlags = await page.route(
    '**/api/account/flags',
    async interceptedRoute => {
      counts.defaultHandler += 1;
      await interceptedRoute.fulfill({
        json: {
          checkoutEnabled: true,
          accountSuspended: false,
          source: 'default-route',
        },
      });
    }
  );

  await page.goto('https://app.test.internal/account');

  const normal = await page.evaluate(async () => {
    const response = await fetch('/api/account/flags');
    return response.json();
  });

  expect(normal).toMatchObject({
    checkoutEnabled: true,
    accountSuspended: false,
    source: 'default-route',
  });

  {
    await using suspendedFlags = await page.route(
      '**/api/account/flags',
      async interceptedRoute => {
        counts.suspendedHandler += 1;
        await interceptedRoute.fulfill({
          status: 403,
          json: {
            checkoutEnabled: false,
            accountSuspended: true,
            source: 'suspended-route',
          },
        });
      }
    );

    const suspended = await page.evaluate(async () => {
      const response = await fetch('/api/account/flags');
      return {
        status: response.status,
        body: await response.json(),
      };
    });

    expect(suspended).toEqual({
      status: 403,
      body: {
        checkoutEnabled: false,
        accountSuspended: true,
        source: 'suspended-route',
      },
    });
  }

  const restored = await page.evaluate(async () => {
    const response = await fetch('/api/account/flags');
    return response.json();
  });

  expect(restored.source).toBe('default-route');
  expect(counts).toEqual({
    defaultHandler: 2,
    suspendedHandler: 1,
  });
});

This test is runnable against an application origin that allows the account page. The API itself does not need to be live because both routes fulfill the requests. The first page navigation remains real.

Reverse-order disposal makes nested scopes predictable. suspendedFlags is removed at its brace. defaultFlags remains until the test callback ends. If multiple Disposable declarations share a block, the last declared resource is disposed first.

Route order still deserves documentation. If the inner handler calls route.fallback() instead of fulfill(), an older matching handler can run. If it calls route.continue(), Playwright sends the request immediately and other matching handlers do not get a turn. These methods express different composition rules. Automatic disposal does not simplify a wrong routing chain.

A page route takes precedence over a browser-context route when both match. Disposing the page route can therefore reveal a context route that was previously hidden. When the next response is still mocked, this precedence is the first place to look. Search fixture setup for context.route() and routeFromHAR() before reporting a disposal defect.

The times option remains useful for an event-count contract:

TypeScript
await page.route(
  '**/api/bootstrap',
  route => route.fulfill({ json: { ready: true } }),
  { times: 1 }
);

A one-use registration expires after its match, which is simpler than a block when the requirement is exactly one request. The trade-off is that no request means it remains registered until page cleanup, and a prefetch can consume it before the intended action. A lexical scope plus a count often provides clearer evidence for action-driven tests.

Do not mix times: 1 with an assumption that disposal proves the handler ran. Disposal only proves the registration was removed. Keep a counter or response marker if matching is part of the claim.

Separate a leaked route from look-alike failures

The strongest signal is a handler counter. Increment it as the first line of the callback and attach the final value. A count that rises after the intended block proves a registration still exists somewhere. A count that stays unchanged while the page receives mocked data proves this particular handler is not the source.

Run the focused case with a trace:

Shell
npx playwright test tests/scoped-price.spec.ts --trace=on
npx playwright show-trace test-results/scoped-price-*/trace.zip

Open the Network tab and select the unexpected request. Playwright marks routed requests there. Compare response status, headers, body source marker, and timing. Route method calls are not necessarily listed as separate actions, so an empty action list around route.fulfill() does not mean routing was absent.

A compact attachment is more useful than dumping every body:

Example
route_scope={
  "matcher":"**/api/price",
  "installedFor":"coupon preview",
  "handlerMatches":1,
  "expectedMatches":1,
  "scopeExited":true
}

Record matcher, intended step, and counts. Do not include authorization headers or complete customer payloads.

Service workers are the closest near-miss. Playwright documents that page.route() does not intercept requests already handled by a service worker. If the handler count is zero but the response resembles an old mock, create a diagnostic context with serviceWorkers: 'block'. A change in behavior points to worker cache or worker fetch logic, not a leaked page handler.

This diagnostic blocks service workers and demonstrates the other common look-alike: disposing a page route reveals an older browser-context fixture.

TypeScript
import { expect, test } from '@playwright/test';

test('reveals the context fixture after the page route is disposed', async ({
  browser,
}) => {
  const context = await browser.newContext({ serviceWorkers: 'block' });

  try {
    await context.route('https://qa.local/', route =>
      route.fulfill({
        contentType: 'text/html',
        body: '<h1>Price diagnostic</h1>',
      })
    );

    await context.route('**/api/price', route =>
      route.fulfill({
        json: { amount: 799, source: 'context-fixture' },
      })
    );

    const page = await context.newPage();
    await page.goto('https://qa.local/');

    {
      await using previewPrice = await page.route(
        '**/api/price',
        route => route.fulfill({
          json: { amount: 499, source: 'page-preview' },
        })
      );

      const preview = await page.evaluate(async () => {
        const response = await fetch('/api/price');
        return response.json();
      });
      expect(preview).toEqual({ amount: 499, source: 'page-preview' });
    }

    const restored = await page.evaluate(async () => {
      const response = await fetch('/api/price');
      return response.json();
    });
    expect(restored).toEqual({
      amount: 799,
      source: 'context-fixture',
    });
  } finally {
    await context.close();
  }
});

Browser-context routes are another source. They survive page-level scope and can apply to popups and new pages in the context. Page routes take precedence, so disposing a temporary page route may expose a fixture's context route. Give fixture routes their own marker and ownership rule.

Application caches can preserve old data even after network routing ends. Check whether the second action issued a request at all. No new Network entry means React Query, an in-memory store, IndexedDB, Cache Storage, or another client cache supplied the value. Reloading without clearing storage may still keep it. Route cleanup cannot invalidate product state.

An in-flight request is different from a future match. If a handler has already received a Route and is awaiting a delayed promise when the scope exits, removing the registration should not be treated as cancellation logic for that callback. Design the test so actions inside the scope settle before leaving it. For broad teardown that must coordinate active handlers, page.unrouteAll() offers documented behavior choices, including waiting, but it removes all page routes and changes a larger ownership boundary.

The opposite timing bug looks like cleanup happened too early. A click can schedule polling, debounce a search, or queue a request in page JavaScript and then return before that request begins. If the block ends immediately after the click, the later request misses the disposed route and reaches upstream. Record the time the scope exits, wait for the specific response that belongs to the action, and assert the handler count before leaving the block. A fixed delay is weak evidence because runner load changes the race. Waiting for the named response or the UI state produced from it makes the resource lifetime match the product operation. The cost is a longer scoped block, but that cost represents real asynchronous work rather than an arbitrary timeout.

A forgotten route action causes a pending request rather than a stale response:

Example
Error: locator.click: Test timeout of 30000ms exceeded
Pending request: GET /api/price
Handler matches: 1
Fulfill calls: 0

The route matched, but one callback branch returned without continue, fulfill, fallback, or abort. Disposal after timeout does not retroactively produce a response. Review branch coverage inside the handler.

A toolchain problem fails earlier. Typical evidence is a parser error around await using or a type error saying the initializer is not disposable. Check:

Shell
npx playwright --version
npx tsc --version

Do not cast the return value to any to silence the error. That can compile code without providing working disposal semantics. Upgrade Playwright and the TypeScript toolchain together, or use explicit try/finally cleanup until the project supports the syntax.

Migrate helpers without making cleanup less visible

Inventory page.route() calls before changing them. Classify each registration by intended lifetime:

  • One request belongs to times: 1 when prefetch cannot steal the match.
  • One action or phase belongs to an await using block.
  • One complete test can remain registered until the page fixture closes.
  • A shared context fixture needs fixture-owned disposal, not page-local cleanup.
  • A route installed only to observe traffic should probably become a request or response event listener.

Start with helpers that install a route and return nothing. Change them to return the Disposable, then make callers own it. Avoid a helper that installs a route and stores registrations in a hidden global list. That recreates implicit lifetime under a different name.

TypeScript
import { test, expect, type Page } from '@playwright/test';

async function installMaintenanceResponse(page: Page) {
  return page.route('**/api/status', interceptedRoute => {
    return interceptedRoute.fulfill({
      status: 503,
      json: { code: 'MAINTENANCE' },
    });
  });
}

test('shows planned maintenance', async ({ page }) => {
  await using maintenanceRoute =
    await installMaintenanceResponse(page);

  await page.goto('https://app.test.internal');
  await expect(page.getByText('Planned maintenance')).toBeVisible();
});

The helper's return type is inferred from page.route(), so it follows the installed Playwright declarations. Callers can see ownership at the use site.

For projects that cannot yet use await using, keep exact handler identity and use finally:

TypeScript
import type { Page, Route } from '@playwright/test';

async function withMaintenanceResponse(
  page: Page,
  action: () => Promise<void>
) {
  const matcher = '**/api/status';
  const handler = (interceptedRoute: Route) => {
    return interceptedRoute.fulfill({
      status: 503,
      json: { code: 'MAINTENANCE' },
    });
  };

  await page.route(matcher, handler);

  try {
    await action();
  } finally {
    await page.unroute(matcher, handler);
  }
}

Passing the same handler reference prevents removal of unrelated handlers for the same matcher. Calling page.unroute(matcher) without a handler removes all routes for that URL pattern, which can break a fixture owned by another layer.

Do not apply automatic and manual cleanup to the same registration during migration. Pick one owner. Double removal may be harmless in one version and still confuses review, error handling, and counters.

Convert a small group, run it repeatedly with one worker and then with normal parallelism, and retain traces for failures. Shared-page leaks often disappear under a fresh per-test page, so include the actual fixture model. If every test receives Playwright Test's isolated page fixture, the business value of converting a test-long route is lower than converting a route that should end halfway through a test.

The same sequence can run as a strict CI script. It type-checks the resource-management syntax, stresses serial reuse, then exercises the normal worker configuration.

Shell
#!/usr/bin/env bash
set -euo pipefail

npx tsc --noEmit
npx playwright test tests/scoped-price.spec.ts \
  --workers=1 \
  --repeat-each=10 \
  --trace=retain-on-failure
npx playwright test tests/scoped-price.spec.ts \
  --trace=retain-on-failure

Measure test duration and protocol traffic after conversion. Many tiny blocks can add cleanup round trips. If that cost is visible, group routes that truly share one phase rather than returning to a suite-wide route.

Document the minimum Playwright version in the same change. A monorepo may have one package on 1.59 and another on 1.58. Code copied across packages will compile differently. The lockfile-resolved version and the CI runtime are the authorities, not an editor's global TypeScript server.

Skip scoped routes when another boundary is clearer

Do not add a temporary route when an event listener answers the question. page.on('response') and page.waitForResponse() observe traffic without modifying it, and they do not disable HTTP cache through routing.

Avoid response mocks in cache, service-worker, performance, streaming, and real integration tests. Installing any route changes network behavior. Automatic cleanup narrows the duration but does not make the routed phase production-equivalent.

A per-test page fixture already closes the page after each test. If a route legitimately applies to the entire test, relying on page ownership can be simpler than wrapping the whole body in another block. Use await using when the route's intended lifetime is shorter or when a reusable page outlives the test phase.

Use browser-context routing when the requirement includes a popup's first request or every page in the context. A page-level Disposable cannot cover traffic it never owns. Give the context registration a context-level lifetime and marker.

Prefer server-side state when several channels must agree. Mocking an account as suspended in one HTTP response while WebSockets and subsequent API calls report it active creates an impossible product state. Seeding the account costs setup time but preserves coherence.

Do not use await using as a substitute for resolving intercepted requests. Every handler branch still needs a route action. Do not leave a scope while the product operation that triggers the route is still running. Wait for the relevant response or UI state first.

Finally, do not force new syntax into a package whose runtime and TypeScript toolchain cannot support it. Manual page.unroute() in finally is correct and explicit. The disposable form earns its keep when it removes a real lifetime hazard, not when it serves as a version badge.

// FIELD DISPATCH

Get the QA Field Notes

Weekly QA battles, AI testing guides, and interview drills. Free on Substack.

// LIVE COURSE / THE TESTING ACADEMY

Playwright Automation Mastery

Go beyond Selenium. Master Playwright with JS/TS in 90 days.

From the instructor behind this guide.

Playwright jobs are growing 8x faster than Selenium. 90 days / 75+ live hrs / Tue-Thu-Sat 7 AM IST.

Code PROMODE / 10% offJoin the batch

The Testing Academy editorial desk

Practical QA guidance built around test evidence, production tradeoffs, and interview-ready explanations.

Published July 25, 2026 / Reviewed August 4, 2026

PRIMARY REFERENCES

Verify the details at the source

QABattle guides are practical explanations. Product behavior, standards, and APIs can change, so use these primary references for the canonical details.

  1. 01
    Official playwright.dev reference

    playwright.dev

    Primary documentation selected and verified for the claims in this guide.

  2. 02
    Official playwright.dev reference

    playwright.dev

    Primary documentation selected and verified for the claims in this guide.

  3. 03
    Official playwright.dev reference

    playwright.dev

    Primary documentation selected and verified for the claims in this guide.

  4. 04
    Official developer.mozilla.org reference

    developer.mozilla.org

    Primary documentation selected and verified for the claims in this guide.

FAQ / QUICK ANSWERS

Questions testers ask

How do I automatically remove a Playwright page route?

Store the Disposable returned by page.route() in an await using declaration. Playwright removes that registration when execution leaves the declaration's block, including exits caused by a thrown error or an early return.

Which version supports await using with page.route?

Playwright 1.59 documents page routes as async disposable resources. The TypeScript toolchain must also understand explicit resource management syntax, so check both Playwright and TypeScript versions when the declaration fails to compile.

Can I still call page.unroute instead of using a Disposable?

Manual cleanup remains useful for older Playwright versions and codebases that cannot parse await using. Keep the same matcher and handler reference, then call page.unroute() in finally so a failed assertion cannot skip removal.

Does disposing a route close the page?

Only the route registration represented by that Disposable is removed. The page remains open unless the page itself is separately owned by an await using declaration or closed through the fixture or test code.

Why is a response still mocked after my page route was disposed?

Another page route, a browser-context route, or a service worker may be serving the request. Count handler calls, inspect the trace network entry, and test in a fresh context with service workers blocked before assuming disposal failed.