To detect every redirect in Playwright, attach a response listener before navigation, then start from the response returned by page.goto() and follow its request’s redirectedFrom() links backward. page.goto() resolves with the last redirect response, not a list of every hop. To control redirects, use routing; to cap automatic following while inspecting a response, use route.fetch({ maxRedirects }).
What Playwright does when a server redirects
A server redirect ends the current request. Playwright creates a new Request for the next URL, and navigation continues. The lifecycle for a request includes request, response, and requestfinished events. A redirect is therefore a chain of distinct requests, not one request whose URL changes.
This distinction explains why looking only at page.url() or the value returned from page.goto() cannot reveal all intermediate destinations. The page URL tells you where navigation ended; the request chain tells you how it got there.
What the navigation response contains
When navigation completes with an HTTP response, page.goto() returns the last redirect response. Its request() gives you the final request in the chain. From there, redirectedFrom() steps back through each earlier request, until it returns null. Each request exposes its URL, so you can reconstruct the chain in order.
The Tool Desk
Outbyte PC Repair FREEClear out junk files and repair common Windows errorsFree Scan →Outbyte Driver Updater FREEScan for outdated or missing drivers - takes under a minuteDriver Scan →#1 Best Overall
The reverse link redirectedTo() points toward the next request. Use it if you start with an earlier request and want to walk forward; use redirectedFrom() when you start with the final navigation response.
Capture and assert the full redirect chain
Register listeners before calling page.goto(), so the earliest response events are not missed. The following TypeScript example runs as a Node.js script, records response URLs and statuses, reconstructs the navigation chain, and checks the final destination.
import { chromium } from 'playwright';
const startUrl = process.argv[2] ?? 'https://example.com';
const expectedFinalUrl = process.argv[3];
async function main() {
const browser = await chromium.launch();
const page = await browser.newPage();
const observedResponses: Array<{ url: string; status: number }> = [];
// Register before navigation so early redirect responses are observed.
page.on('response', response => {
observedResponses.push({
url: response.url(),
status: response.status(),
});
});
try {
const finalResponse = await page.goto(startUrl);
if (!finalResponse) {
throw new Error('Navigation produced no response');
}
const chain: string[] = [];
for (
let request = finalResponse.request();
request;
request = request.redirectedFrom()
) {
chain.unshift(request.url());
}
const result = {
chain,
finalUrl: finalResponse.url(),
status: finalResponse.status(),
observedResponses,
};
console.log(JSON.stringify(result, null, 2));
if (expectedFinalUrl && finalResponse.url() !== expectedFinalUrl) {
throw new Error(
`Unexpected final URL: ${finalResponse.url()}`
);
}
} finally {
await browser.close();
}
}
main().catch(error => {
console.error(error);
process.exitCode = 1;
});
Run it with a starting URL and, optionally, the destination you expect:
node detect-redirects.js https://example.com/start https://example.com/final
The chain array is ordered from the starting URL through the final request. observedResponses records response events and their statuses as they arrive. Treat it as an event log, not as the canonical chain: a page can make requests beyond its main navigation, while the chain reconstructed from finalResponse.request() specifically follows the navigation response’s redirect ancestry.
Recommended Free Tools
Rank #2
- Brand: Wiley
- Set of 2 Volumes
- A handy two-book set that uniquely combines related technologies Highly visual format and accessible language makes these books highly effective learning tools Perfect for beginning web designers and front-end developers
Choose assertions that match the behavior you need
- Final destination: Assert
finalResponse.url()or, after navigation,page.url()against the expected canonical URL. - Every hop: Assert the complete array, not just its first and last entries. This catches unexpected intermediate hosts, path changes, and scheme changes.
- Status contract: Record response statuses and assert the redirect and final statuses your application is supposed to return. Do not assume a particular redirect count.
- Timing: Attach response listeners before navigation. Adding them after
page.goto()can miss earlier responses.
Intercept, rewrite, or block a request
Use page.route() or browserContext.route() when the test needs to control traffic rather than only observe it. A route handler can continue ordinary traffic, abort an unwanted request, or fulfill a response with test data.
await page.route('**/account', async route => {
await route.continue();
});
await page.route('**/unwanted', async route => {
await route.abort();
});
await page.route('**/test-data', async route => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ ok: true }),
});
});
Routing treats a request and its redirects as one unit. In the usual redirect flow, the route handler sees the original request while the browser follows the chain; it does not provide a fresh interception point for every hop. This matters when designing a test: use the request-chain links or response events to observe hops, and use routing to decide what happens to the intercepted request.
To send a request to another URL, pass the replacement URL to route.continue() or route.fallback(). To stop an unwanted request, abort it. To supply known test content, fulfill it directly. These approaches serve different purposes: rewriting sends traffic elsewhere, aborting prevents it from proceeding, and fulfilling returns a test response.
Inspect a response with route.fetch()
route.fetch() makes the request and returns a response that the handler can inspect or modify before fulfilling the browser’s request. It follows redirects automatically, unless the configured maxRedirects limit is exceeded. In that case, the call throws, which gives tests a way to fail predictably on excessive redirect depth.
Rank #3
await page.route('**/login', async route => {
const response = await route.fetch({ maxRedirects: 5 });
const body = await response.text();
await route.fulfill({ response, body });
});
Choose a limit based on the expected behavior in your test. The option is a cap, not a promise that a site will redirect a particular number of times; there is no need to assume a fixed default. If the actual chain exceeds your configured limit, handle the thrown error as the test outcome rather than expecting a response object.
Do not fulfill a 3xx response to trigger another route pass
A fulfilled redirect response is not a portable way to make Playwright re-enter the route handler for the destination. Chromium and Firefox follow a fulfilled 3xx redirect without calling the handler again; WebKit rejects that call. If the test needs alternate content, fulfill that content directly. If it needs the request sent to another location, use route.continue() or route.fallback() with a URL instead.
Common failures and how to diagnose them
The chain contains only the final URL
Start traversal from finalResponse.request() and repeatedly call redirectedFrom(); do not expect page.goto() itself to return every response. Also make sure the listener was registered before navigation if you are relying on events to record early response statuses.
Navigation has no response object
Check for the explicit null case before calling request(). A navigation that produces no response cannot supply the response-backed chain shown above, so report that condition separately instead of treating it as an empty redirect chain.
Free tools Windows power users keep installed
One-click scans. No signup required.
Rank #4
- HTML CSS Design and Build Web Sites
- Comes with secure packaging
- It can be a gift option
A 404 or 503 is mistaken for a network failure
HTTP error statuses still complete as HTTP responses; they are not, by themselves, requestfailed network errors. Inspect the response status when the server answered, and reserve network-failure handling for requests that actually fail at the network level.
route.fetch() throws while following a chain
Check whether the redirect sequence exceeds the configured maxRedirects. If it does, the fetch throws by design. Set a limit that allows the redirects the application legitimately requires, while retaining a finite cap when the test must detect a loop or unexpectedly deep chain.
A route handler does not see a redirect destination
That is consistent with routing’s request-and-redirect unit behavior. Do not rely on the handler being invoked again for every hop or for a fulfilled 3xx response. Use response events and request-chain traversal for observation, or rewrite the original request to the destination when the test requires routing control.
Routing changes caching or misses a service-worker request
Routing can disable the HTTP cache, and requests intercepted by service workers may bypass page.route(). If either condition affects the test, consider context-level routing or blocking service workers for that setup. Verify the behavior in the same browser and context configuration used by the test rather than assuming a page route will observe every request.
Best Value
Performance and test reliability
For a navigation assertion, reconstructing the chain after page.goto() is direct and avoids treating unrelated response events as redirects. Add event listeners when you need statuses captured as responses arrive; keep their recorded data scoped to the navigation under test if the page also makes background requests.
Use a finite maxRedirects when fetching through routes and when redirect loops or unbounded chains are a test concern. Avoid using mocked 3xx fulfillment as a cross-browser redirect mechanism, because its behavior differs between Chromium, Firefox, and WebKit. Finally, distinguish an HTTP response with an error status from a network failure: conflating the two can make a test pass or fail for the wrong reason.
Redirect behavior can also depend on the requested URL, response headers, cookies, and application state. Make assertions about the chain and destination that form part of the application contract, rather than assuming one universal URL sequence for every run.
Or skip the browser setup
ScreenshotNeo is a website screenshot API, not a replacement for Playwright’s redirect-chain assertions. If you also need a clean screenshot of a URL, one GET request can return an image or PDF; the API and available options are in the ScreenshotNeo documentation.
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
ScreenshotNeo removes cookie and consent banners, newsletter popups, and chat widgets before the shot. Bot checks, blank pages, and failed loads are not billed. Its MCP server gives AI agents tools to take screenshots, inspect page information, and capture PDFs. The Free plan includes 1,000 shots a month with no card; paid plans start at $5 for 3,000 shots. Every feature is available on every plan. Learn more at ScreenshotNeo, or sign up free.
Frequently Asked Questions
Can I determine whether the redirect changed only the scheme or host?
Yes. Compare the full URLs in the reconstructed chain, not only their hostnames or paths; the chain includes each request URL.
Should I use response events or redirectedFrom() as my source of truth?
Use the final navigation response’s request chain to establish the navigation hops. Response events are useful for capturing statuses as they arrive, but their log is not limited to that chain.
Quick Recap
Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon at the time of purchase will apply.

