PRACTICAL GUIDE / Playwright continue versus fallback
Why your second Playwright route handler never runs
Learn when to end a Playwright request chain with continue(), when to delegate with fallback(), and how to prove which route handler actually ran.
In this guide6 sections
- Why one line changes the whole handler chain
- Use continue() when this handler owns the exit
- Use fallback() when another handler still has work
- Prove which handler ran before blaming the backend
- Migrate a large suite without changing every request at once
- Know the cost, and when a route chain is the wrong tool
What you will learn
- Why one line changes the whole handler chain
- Use continue() when this handler owns the exit
- Use fallback() when another handler still has work
- Prove which handler ran before blaming the backend
A login test reaches the real user service even though an earlier route handler was supposed to return a fixture. The newer handler calls route.continue(), so Playwright never gives the older mock a turn. Changing one method to route.fallback() can restore the mock, but it also changes who owns the request and what later handlers can alter.
That distinction matters once a suite has more than one interception layer. A shared fixture may add authentication, a project fixture may block analytics, and one test may mock a single endpoint. All three can match the same request. The code looks like a set of independent callbacks, but Playwright treats those callbacks as an ordered chain with one terminal outcome.
Why one line changes the whole handler chain
Every request matched by page.route() or browserContext.route() is paused until a handler resolves it. A handler has four common choices: fulfill it with a response, abort it, send it to the network with continue(), or delegate it with fallback(). The first three choices are terminal for that route chain. Only fallback() asks Playwright to look for another matching handler.
The word “continue” is easy to misread. In middleware libraries, “continue” often means “continue through the remaining middleware.” Here it means “continue this HTTP request to the network now.” That is why a route added for harmless header logging can disable a fixture registered earlier. The logger was not harmless once it ended with continue().
Matching handlers run in reverse registration order. If a fixture registers handler A and a test later registers handler B, B runs first. A runs only when B calls fallback(). Registering a third handler C after both makes the order C, B, A. This stack-like order lets a local test override broad shared behavior, provided every handler delegates requests it does not own.
Consider three decisions for one GET request to /api/profile:
- A context-level route provides the default network path.
- A page-level fixture returns a stable profile.
- A test-level route adds a diagnostic header.
The diagnostic route is newest, so it sees the request first. Calling continue() there bypasses both the profile fixture and any remaining matching route logic. Calling fallback({ headers }) lets the profile fixture see the request with the accumulated override. If that fixture fulfills, the real server is never contacted. If it also falls back, control proceeds toward the default path.
Optional overrides do not change that ownership rule. Both methods can override headers, method, postData, and URL. With fallback(), those values are available to the remaining chain and eventual request. With continue(), the overrides go directly to the network because no matching handler follows. URL rewrites have another sharp edge: subsequent route matching still uses the original request URL, not the rewritten one. A handler cannot rewrite /api/v1/users to /api/v2/users and expect a pattern that only matches /api/v2/** to join halfway through the chain.
Redirects also deserve a precise mental model. Header overrides apply to the routed request and redirects it initiates. URL, method, and postData overrides apply only to the original request. A test that changes POST to GET for the first hop has not promised that same override for a redirected hop. Recording the final method and URL in the trace is more reliable than assuming the first handler describes the whole exchange.
Some request headers remain browser-controlled. Playwright documents Cookie, Host, and Content-Length among the values that cannot be replaced through these route options. An ignored Cookie override can look like a handler-order bug because the request reaches the correct layer but still carries the old session. Use browserContext.addCookies() when cookie state is the behavior under test.
One more ordering rule sits outside the immediate chain: page routes take precedence over browser context routes when both match. Broad page-level interception is therefore risky in a suite that installs context-level defaults. Keep the narrowest handler closest to the test, and make its terminal choice explicit in review. “This handler owns the response” justifies fulfill(), abort(), or continue(). “This handler observes or modifies, but another layer owns the result” calls for fallback().
Use continue() when this handler owns the exit
A terminal network path is legitimate. A test may deliberately bypass a default mock to exercise a staging dependency, or a narrow exception may permit one asset while a broader route blocks everything else. The important part is that the handler choosing continue() owns that decision. It should not look like generic pass-through code in a reusable helper.
The following test is self-contained. It starts a local HTTP server, installs an older fixture response, then installs a newer bypass. The browser receives the live server payload, and the handler log proves the fixture never ran. Save it as route-continue.spec.ts and run it with Playwright Test.
import { createServer } from 'node:http';
import type { AddressInfo } from 'node:net';
import { expect, test } from '@playwright/test';
function startApp(): Promise<{ url: string; close: () => Promise<void> }> {
return new Promise((resolve, reject) => {
const server = createServer((request, response) => {
if (request.url === '/api/profile') {
response.writeHead(200, { 'content-type': 'application/json' });
response.end(JSON.stringify({ source: 'live-server', name: 'Asha' }));
return;
}
response.writeHead(200, { 'content-type': 'text/html' });
response.end(
'<button id="load">Load profile</button>' +
'<pre id="result"></pre>' +
'<script>' +
'document.querySelector("#load").onclick = async () => {' +
' const response = await fetch("/api/profile");' +
' document.querySelector("#result").textContent =' +
' JSON.stringify(await response.json());' +
'};' +
'</script>',
);
});
server.once('error', reject);
server.listen(0, '127.0.0.1', () => {
const { port } = server.address() as AddressInfo;
resolve({
url: 'http://127.0.0.1:' + port,
close: () =>
new Promise<void>((resolveClose, rejectClose) => {
server.close(error =>
error ? rejectClose(error) : resolveClose(),
);
}),
});
});
});
}
test('continue sends immediately and skips older handlers', async ({ page }) => {
const app = await startApp();
const handlers: string[] = [];
try {
await page.route('**/api/profile', async route => {
handlers.push('profile-fixture');
await route.fulfill({
json: { source: 'fixture', name: 'Fixture User' },
});
});
await page.route('**/api/profile', async route => {
handlers.push('live-bypass');
await route.continue();
});
await page.goto(app.url);
await page.getByRole('button', { name: 'Load profile' }).click();
await expect(page.locator('#result')).toHaveText(
'{"source":"live-server","name":"Asha"}',
);
expect(handlers).toEqual(['live-bypass']);
} finally {
await app.close();
}
});The two assertions answer different questions. The DOM assertion proves what the application consumed. The handler assertion proves why. Without the second assertion, a fixture containing the same data as the server could hide the routing mistake. Without the first, the route log could be correct while the application ignores the response.
This use of continue() costs determinism. Even when the destination is an internal test environment, its data, availability, TLS configuration, and latency now affect the result. A local server keeps this demonstration repeatable, but a production suite that continues to a shared service needs separate ownership for seed data and outages. Do not label that exposure as a pure browser test.
The same terminal behavior is useful for an allowlist beneath a blocking policy. Register the blocking route first, then register narrow exceptions later. An exception that calls continue() intentionally prevents the older blocker from aborting that request. Reviewers should be able to name which hosts or paths escape and why. A generic */ exception with continue() defeats the policy and is almost impossible to spot from a green UI assertion.
Use fallback() when another handler still has work
Delegation fits cross-cutting behavior. Correlation headers, tenant selection, request logging, and method-specific dispatch often need to run without deciding the final response. Those layers should call fallback(), including their “not applicable” branch. The next matching handler can then mock, abort, delegate again, or own the network exit.
This example has a broad tenant layer and an older network owner. The newest layer adds a header, then falls back. The older handler records its turn and continues to a local server. The server echoes the header, proving that the override survived delegation.
import { createServer } from 'node:http';
import type { AddressInfo } from 'node:net';
import { expect, test } from '@playwright/test';
function startOrderApp(): Promise<{ url: string; close: () => Promise<void> }> {
return new Promise((resolve, reject) => {
const server = createServer((request, response) => {
if (request.url === '/api/orders') {
response.writeHead(200, { 'content-type': 'application/json' });
response.end(
JSON.stringify({
count: 2,
tenant: request.headers['x-qa-tenant'] ?? null,
}),
);
return;
}
response.writeHead(200, { 'content-type': 'text/html' });
response.end(
'<button id="load">Load orders</button>' +
'<pre id="result"></pre>' +
'<script>' +
'document.querySelector("#load").onclick = async () => {' +
' const response = await fetch("/api/orders");' +
' document.querySelector("#result").textContent =' +
' JSON.stringify(await response.json());' +
'};' +
'</script>',
);
});
server.once('error', reject);
server.listen(0, '127.0.0.1', () => {
const { port } = server.address() as AddressInfo;
resolve({
url: 'http://127.0.0.1:' + port,
close: () =>
new Promise<void>((resolveClose, rejectClose) => {
server.close(error =>
error ? rejectClose(error) : resolveClose(),
);
}),
});
});
});
}
test('fallback delegates with request overrides', async ({ page }) => {
const app = await startOrderApp();
const handlers: string[] = [];
try {
await page.route('**/api/orders', async route => {
handlers.push('network-owner');
await route.continue();
});
await page.route('**/api/**', async route => {
handlers.push('tenant-layer');
await route.fallback({
headers: {
...route.request().headers(),
'x-qa-tenant': 'eu-test',
},
});
});
await page.goto(app.url);
await page.getByRole('button', { name: 'Load orders' }).click();
await expect(page.locator('#result')).toHaveText(
'{"count":2,"tenant":"eu-test"}',
);
expect(handlers).toEqual(['tenant-layer', 'network-owner']);
} finally {
await app.close();
}
});Registration order is part of this example’s contract. Swapping the two page.route() calls makes the narrow network owner run first and terminate the chain. The tenant layer then disappears. That dependency is the main cost of composing routes this way: a reader must understand both match scope and registration time. Put related registrations next to each other, or wrap them in one fixture whose name communicates the order.
Method dispatch is another place where fallback() earns its keep. A GET responder should not continue every non-GET request to the real backend if a POST responder may be registered behind it. “I do not own this method” is delegation, not network permission.
The next test runs without a server because every browser request is fulfilled. The POST handler is registered last, so it sees GET first and falls back. The GET handler then returns the list. On POST, the newest handler owns the request and returns immediately.
import { expect, test } from '@playwright/test';
test('method handlers delegate requests they do not own', async ({ page }) => {
const handlers: string[] = [];
await page.route('https://qa.local/', async route => {
await route.fulfill({
contentType: 'text/html',
body: '<h1>Orders</h1>',
});
});
await page.route('**/api/items', async route => {
const method = route.request().method();
handlers.push('get-handler:' + method);
if (method !== 'GET') {
await route.fallback();
return;
}
await route.fulfill({
json: [{ id: 1, name: 'Existing item' }],
});
});
await page.route('**/api/items', async route => {
const method = route.request().method();
handlers.push('post-handler:' + method);
if (method !== 'POST') {
await route.fallback();
return;
}
const input = route.request().postDataJSON() as { name: string };
await route.fulfill({
status: 201,
json: { id: 2, name: input.name },
});
});
await page.goto('https://qa.local/');
const result = await page.evaluate(async () => {
const listResponse = await fetch('/api/items');
const list = await listResponse.json();
const createResponse = await fetch('/api/items', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ name: 'New item' }),
});
const created = await createResponse.json();
return {
listStatus: listResponse.status,
list,
createStatus: createResponse.status,
created,
};
});
expect(result).toEqual({
listStatus: 200,
list: [{ id: 1, name: 'Existing item' }],
createStatus: 201,
created: { id: 2, name: 'New item' },
});
expect(handlers).toEqual([
'post-handler:GET',
'get-handler:GET',
'post-handler:POST',
]);
});This design still has a failure branch worth testing. A DELETE request falls through POST, then GET, and has no API owner after both delegate. Depending on the surrounding routes, it may reach the network or another policy handler. If DELETE must never escape, register an older catch-all for /api/ that aborts unexpected methods or fulfills a deliberate error. That catch-all is the bottom of the chain, not another observer.
Here is a complete guard test for that bottom-of-chain policy. It registers the catch-all first so the newer method handlers can delegate an unexpected method to it.
import { expect, test } from '@playwright/test';
test('unexpected API methods stop at the oldest policy route', async ({
page,
}) => {
const decisions: string[] = [];
await page.route('**/api/**', async route => {
decisions.push('policy:' + route.request().method());
await route.fulfill({
status: 405,
headers: { allow: 'GET, POST' },
json: { error: 'method-not-allowed' },
});
});
await page.route('**/api/items', async route => {
if (route.request().method() === 'GET') {
await route.fulfill({ json: [] });
return;
}
decisions.push('get:fallback');
await route.fallback();
});
await page.route('**/api/items', async route => {
if (route.request().method() === 'POST') {
await route.fulfill({ status: 201, json: { id: 2 } });
return;
}
decisions.push('post:fallback');
await route.fallback();
});
await page.route('https://qa.local/', route =>
route.fulfill({
contentType: 'text/html',
body: '<h1>Route policy</h1>',
})
);
await page.goto('https://qa.local/');
const result = await page.evaluate(async () => {
const response = await fetch('/api/items', { method: 'DELETE' });
return {
status: response.status,
allow: response.headers.get('allow'),
body: await response.json(),
};
});
expect(result).toEqual({
status: 405,
allow: 'GET, POST',
body: { error: 'method-not-allowed' },
});
expect(decisions).toEqual([
'post:fallback',
'get:fallback',
'policy:DELETE',
]);
});Prove which handler ran before blaming the backend
A timeout after clicking “Load profile” does not identify the routing error. The request may have reached the wrong handler, missed all handlers, stalled inside a handler that never resolved the route, or completed with a response the page rejected. Start with evidence that distinguishes those states.
Add a small route log while diagnosing. Record a stable layer name, the method, the original URL, and the terminal choice. Keep credentials and request bodies out unless the test data is known to be safe. An array local to the test is better than a process-global logger because workers can interleave process output.
For example, a handler can append an entry just before its decision:
routeLog.push({
layer: 'profile-override',
method: route.request().method(),
url: route.request().url(),
decision: 'fallback',
});
await route.fallback();Assert the expected order near the product assertion. If someone changes fallback() to continue(), a useful failure names the missing layer:
Error: expect(received).toEqual(expected) // deep equality
- Expected
+ Received
[
"profile-override:fallback",
- "shared-profile-fixture:fulfill",
]That message is more actionable than “expected profile name Fixture User, received Asha.” It points to route ownership, not test data. Keep the final UI or API assertion as well, because route order alone does not prove the application accepted the response.
Run the failing file alone first:
npx playwright test tests/routing/profile.spec.ts --project=chromium --workers=1 --trace=onThen open the trace produced for that run:
npx playwright show-trace test-results/path-to-test/trace.zipThe exact output directory includes the project and test name, so copy the trace path from Playwright’s terminal or HTML report rather than guessing it. In Trace Viewer, select the action that triggered the request and open Network. Inspect the request URL, method, request headers, status, and response body. The Console panel contains logs emitted from the test and browser, which is useful if route decisions were logged during the action.
Trace Viewer shows the final network exchange, not a magical diagram of every matching callback. That is why the explicit route log matters. If Network contains /api/profile with the live response and the log contains only live-bypass, continue() terminated the chain. If Network contains the fixture body and both route entries appear, handler order is working and the bug sits after interception, often in application parsing or state updates.
An empty route log points somewhere else. Check these near-misses before editing the method:
- A service worker may own the request. Playwright’s network guide recommends serviceWorkers: 'block' when native page or context routing appears to miss events. That setting makes interception tests predictable, but it changes product behavior if offline caching is what the test is meant to cover.
- The route may have been registered after the request began. Install routes before page.goto(), before clicking, or before application code schedules the fetch. A trace timestamp before the registration step confirms this race.
- The glob may not match the URL you think it matches. Read the complete URL from Trace Viewer, including host, path, and query. Matchers are evaluated against the original URL, even when fallback() later overrides the URL.
- A popup’s first navigation is not intercepted by page.route() on the opener. Playwright documents browserContext.route() for that first popup request. Later page requests can use page-level routes normally.
- A redirect may move the interesting response to another URL. The route handler is called for the first matched URL in that redirect chain, while some overrides do not carry to later hops.
A non-empty log that stops at “entered handler” usually means the callback did not resolve the route. Every branch must await fulfill(), abort(), continue(), or fallback(). Look especially for early returns, exceptions while parsing postDataJSON(), and conditions that handle GET but forget an else branch. The browser request stays paused, so the visible failure is often an action or navigation timeout several seconds later.
Do not use page.on('requestfailed') as proof that a 404 or 500 route failed. Those are valid HTTP responses and therefore do not emit requestfailed merely because of status. Network errors such as connection refusal do. Inspect response status or assert the Response object when status is the evidence you need.
Cache behavior can create another misleading comparison with a manual browser session. Enabling routing disables HTTP cache. A route-enabled test may contact the server when an uncontrolled session uses a cached resource. That difference is expected, and swapping continue() for fallback() will not restore cache realism.
Migrate a large suite without changing every request at once
Blindly replacing every continue() with fallback() is dangerous. Some handlers intentionally bypass blockers or mocks, and delegation would expose them to older terminal handlers. Treat the migration as an ownership audit.
Start by finding registration sites and terminal decisions. A repository-level inventory can be produced without running tests:
rg -n "page\.route|context\.route|route\.(continue|fallback|fulfill|abort)" tests e2eGroup results by fixture rather than by file count. For each overlapping pattern, write down registration order, match scope, and one of three roles:
- An owner chooses fulfill(), abort(), or continue() and ends the chain.
- A modifier changes headers, method, URL, or postData, then delegates.
- An observer records evidence and delegates without changing the request.
Observers and modifiers normally end in fallback(). Owners need a comment or fixture name that explains their terminal choice. A callback called passThrough that invokes continue() is ambiguous because “pass through” could mean network or next handler. Names such as sendProfileToStaging and delegateWithTenantHeader are harder to misuse.
Protect the current behavior before changing shared fixtures. Add small routing tests like the three examples above. Assert both the handler sequence and the delivered response. Include one request the layer owns and one it must delegate. For method routers, exercise at least the supported methods plus an unexpected one. These tests are cheap and catch future registration-order changes before dozens of product tests fail.
Move one layer at a time. A practical sequence is:
- Instrument the shared handlers with stable layer names.
- Narrow overly broad patterns where the URL alone can express ownership.
- Convert pure observers from continue() to fallback().
- Convert request modifiers and assert that the older owner receives their effective changes.
- Leave intentional network exits on continue(), with a focused test proving older mocks do not run.
- Remove temporary logs only after the route-order assertions provide equivalent evidence.
Run the routing contract tests with one worker while developing, because a serial log is easier to read. Then run the affected project with its normal worker count. Route arrays and server state must live inside each test or fixture, not in a shared mutable module. Parallel failures after an otherwise correct migration usually signal shared diagnostic state or a local test server that uses one global expectation.
Version support belongs in the rollout checklist. route.fallback() was added in Playwright 1.23. A monorepo may contain more than one package lock or an older utility project even when the main suite is current. Check the version used by the job that executes the changed tests, not only the root package manifest.
Page-level and context-level ownership should also be consistent. Context routes fit policies that apply to every page in that context, including the first request of a popup. Page routes fit behavior owned by one page or one test. Since page routes take precedence, adding a broad page handler can change a context fixture without editing that fixture. A rollout review should therefore consider both scopes together.
Cleanup matters when a page or context survives across tests. Remove temporary routes when their intended lifetime ends, or prefer Playwright Test’s isolated page and context fixtures so closure discards the handlers. A leaked late registration changes the first handler seen by the next test and can make failures order-dependent. If a suite deliberately reuses a context, make route installation and removal part of that fixture’s contract.
Canary the migration in a small set of tests that cover mocked, real-network, redirected, and popup requests. Keep trace collection on first retry in CI. A retry that passes is still useful evidence here because different request timing can expose late route registration, but retries should not turn the job green without investigation. Compare the route log from both attempts.
A dedicated CI job keeps that contract suite serial and retains a trace from any failing run:
name: routing-contract
on:
pull_request:
workflow_dispatch:
jobs:
playwright-routing:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npx playwright install --with-deps chromium
- run: >
npx playwright test tests/routing
--project=chromium
--workers=1
--trace=retain-on-failureKnow the cost, and when a route chain is the wrong tool
Delegating handlers reduce duplication, but the abstraction charges interest. Registration order becomes behavior. Someone reading a test must locate every matching page and context route to predict the result. Each broad matcher also runs for more requests, producing extra callback work and larger diagnostics. The latency may be small per request and noticeable across a page that loads hundreds of assets.
Fallback chains can also weaken coverage. A stable fixture at the bottom may silently satisfy a request that a new test author expected to reach the backend. Continue() has the opposite risk: one late generic handler can silently bypass mocks and expose CI to a shared environment. Handler-sequence assertions make those choices visible, but they add maintenance whenever architecture changes.
Request modification introduces its own coupling. A header added by one layer can change authentication, caching, content negotiation, or server-side routing for every older owner. Header overrides continue through redirects, while method, URL, and postData changes do not. That asymmetry can turn a simple chain into a redirect-specific test matrix.
Avoid multiple matching handlers when one callback can express the rule clearly. A single route with a switch on method or pathname is often easier to review for a test that owns only two endpoints. Separate handlers become valuable when independently maintained fixtures need composition, not merely because fallback() exists.
Do not use interception as a substitute for a contract test. A fulfilled response proves that the browser handles the fixture you wrote. It does not prove the real service still returns that schema, honors authentication, or implements error behavior. Keep API contract or integration coverage beside the fast browser mock, and make it obvious which test crosses the network.
Performance and cache tests should avoid page.route() and browserContext.route() because routing disables HTTP cache. A timing result from a routed page does not represent a normal cached browser visit. The same warning applies to tests whose purpose is the browser’s caching policy.
Service worker behavior needs its own lane. Blocking service workers is appropriate when they only interfere with deterministic API mocking. It is not appropriate when the test covers offline support, background updates, cache invalidation, or requests initiated by the worker itself. Those scenarios require service-worker-aware evidence rather than forcing every request through page.route().
HAR replay may be simpler when the requirement is “serve this recorded dependency set” rather than “compose several decisions per request.” Playwright’s routeFromHAR() has its own notFound option, including a value named fallback. That option controls what happens when the HAR lacks an entry. It is not the same operation as Route.fallback(), and it should not be used as evidence about handler order.
Finally, skip a chain when failure ownership would be unclear. If a request can be mutated by four fixtures, mocked by a fifth, and allowed to the network by a sixth, the suite has built a network policy engine inside its tests. Consolidate the rules, narrow the matchers, or split the scenarios. Two explicit tests, one mocked and one integrated, are usually cheaper than one test whose request destination depends on six reverse-ordered callbacks.
// 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.
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.
- 01Official playwright.dev reference
playwright.dev
Primary documentation selected and verified for the claims in this guide.
- 02Official playwright.dev reference
playwright.dev
Primary documentation selected and verified for the claims in this guide.
- 03Official playwright.dev reference
playwright.dev
Primary documentation selected and verified for the claims in this guide.
- 04Official playwright.dev reference
playwright.dev
Primary documentation selected and verified for the claims in this guide.
FAQ / QUICK ANSWERS
Questions testers ask
Why does my second page.route handler never run?
The most recently registered matching handler runs first. If that handler calls route.continue(), Playwright sends the request immediately and does not invoke any other matching handler; use route.fallback() when an older handler still needs a turn.
Does route.fallback() send the request to the network?
Not immediately when another matching handler remains. It passes control to the next handler, which can fulfill, abort, continue, or fall back again; the request reaches the network only if the chain eventually chooses a network path.
What order do multiple Playwright route handlers run in?
Handlers execute in reverse registration order, so the last matching route registered gets the first decision. That ordering makes late test-specific overrides possible, but it also means a broad late handler can unexpectedly hide an earlier fixture.
Can route.fallback() change headers for the next handler?
Header overrides can travel through fallback to later handlers and the eventual request. Some browser-controlled headers, including Cookie and Host, cannot be replaced this way, so set cookies through the browser context instead.
Why is no Playwright route handler seeing my request?
A service worker, a mismatched URL pattern, or route registration after the request started can all produce an empty handler log. Check the trace Network tab and the final request URL before changing continue() to fallback().
RELATED GUIDES
Continue the learning route
GUIDE 01
Playwright existingResponse Route Request Testing
Learn Playwright request existingResponse route testing with working code, failure cases, debugging steps, and CI evidence for reliable QA automation.
GUIDE 02
Debug Missing Playwright Route Events Behind Service Workers and Caches
Trace missing Playwright route events through page, service worker, and cache ownership, then choose deterministic interception or worker-aware assertions.
GUIDE 03
How to Take Screenshots in Playwright
Learn how to take screenshots in Playwright for full page, element, and failure captures, plus visual checks and practical CI debugging tips.
GUIDE 04
How to Run Tests in Parallel with Playwright
Learn how to run tests in parallel with Playwright: workers, sharding, isolation, CI config, flakiness fixes, and a practical checklist for faster suites.
GUIDE 05
Playwright Tutorial: End-to-End Testing from Scratch
Playwright tutorial for beginners: install, write your first test, TypeScript setup, codegen, fixtures, API testing, debugging, and CI tips in one guide.