← all articles

Why a Playwright upgrade can silently break your agent's CORS requests

Most of the Playwright upgrades I run on agent fleets are boring. I bump the version, the type checker is happy, the smoke test passes, and I move on. The ones that hurt are the ones where nothing fails loudly. A request that used to return 200 now returns an empty body, or a preflight that used to pass is now rejected, or an Origin header that a backend allowlist expected is missing or different. The agent keeps running and the run log looks normal. What changes is the data the agent reasons over.

The stakes are higher for agents than for ordinary end-to-end tests. A test suite that breaks on CORS goes red and a human looks at it. An agent that calls a first-party API from inside a page, or that routes requests through page.route() to inject auth, will often swallow the failure. A fetch() that fails CORS rejects with a bare TypeError: Failed to fetch. Most agent loops treat that as “the tool returned nothing” and carry on, or retry and burn tokens. I have seen a model confidently summarise an empty result because the tool wrapper returned an empty list instead of an error.

This article is about the mechanism behind those silent breaks, with one concrete reader question in mind: the thread at microsoft/playwright issue 37769, which asks why the Origin header behaves differently between Playwright versions. I will answer that directly in the core mechanism section, tell you what I can and cannot confirm, and give you a canary test you can run in CI so the next change in this area fails a build instead of a production run.

Background and prior art

CORS is a browser-enforced contract, not a server feature. The server sends Access-Control-Allow-* response headers, and the browser decides whether page JavaScript may read the response. The authoritative description of the protocol is the WHATWG Fetch standard, and the most readable operator-level explanation is the MDN CORS guide. Two facts from those documents matter for everything below. First, the browser attaches an Origin request header on cross-origin requests, and also on same-origin requests that are not GET or HEAD. Second, “simple” requests go straight out, while anything with a non-safelisted method, header or content type triggers an OPTIONS preflight first.

Playwright sits on top of that contract in three different ways, and people tend to blur them together:

  • the browser binary: each Playwright release pins specific builds of Chromium, Firefox and WebKit, so upgrading Playwright is also upgrading three browsers. Their CORS, cookie and header-handling behaviour comes along with it
  • the interception layer: page.route() and context.route() sit between the page and the network, and whatever you do in the handler (continue, fulfill, fetch) decides what the browser sees
  • the API client: page.request, context.request and route.fetch() use APIRequestContext, which makes requests from the Node side and is not a browser, so it does not apply CORS at all

Prior art here is mostly scattered across release notes and issue threads. The Playwright release notes list the browser versions bundled with each release, and that table is the first thing I open when something network-shaped changes after an upgrade. The Origin question in issue 37769 is one of several threads where the surprise is the same: the code did not change, the version did, and the wire behaviour moved.

If you are still choosing a driver at all, I compared the trade-offs in Playwright vs Puppeteer for AI browser agents. Everything below applies to Playwright specifically, but the “your browser binary changed under you” lesson is true for any driver that pins its own browsers.

The core mechanism

Here is the direct answer to the question in issue 37769. When the Origin header differs between two Playwright versions, there are only three places the difference can come from, and you can tell them apart with a short bisect.

Cause one is the bundled browser. Each Playwright version ships a pinned Chromium, Firefox and WebKit. Browser vendors change when Origin is sent, what it contains for opaque contexts, and how preflights are cached. A release that moves Chromium forward by several major versions can change wire behaviour with zero changes in Playwright’s own code.

Cause two is Playwright’s own network layer. When you intercept a request, the browser’s request is held, and your handler decides the outcome. If you call route.fulfill() on a cross-origin request, Playwright has to satisfy the CORS check on the browser’s behalf, because the page is still a real page with real CORS enforcement. How Playwright synthesises or forwards those headers, and how route.fetch() carries over (or does not carry over) the original request’s headers, is Playwright code and can change between releases. The route.fetch() documentation describes the API, but the exact header carry-over is the kind of detail I never trust without a test.

Cause three is your own harness. page.setContent() on about:blank, file:// pages, data: URLs and sandboxed iframes all have opaque origins, and requests from them carry Origin: null. If a server allowlist keyed on a real origin was passing before because of a different navigation pattern, a refactor that happened to land in the same PR as the version bump looks like a Playwright regression and is not one.

I need to be straight about the limits of what I can say here. I have not bisected the specific version pair in issue 37769 myself, so I am not going to put version numbers on the change or quote what the maintainers concluded. Read the thread for the exact versions and the resolution. What I can give you is the method that tells you which of the three causes you are hitting:

  1. run the same test with the old and new Playwright versions but the same browser binary (install the old browser build explicitly, or use the old Playwright with the new browser’s channel where you can). If the header differs, it is Playwright’s layer or your harness
  2. run old and new Playwright with page.route() removed entirely. If the difference disappears, it is the interception layer
  3. serve a tiny server that logs raw request headers (code below) and compare the log between runs. Never debug this from the browser devtools alone, because devtools shows you what the browser intended to send, not always what reached the wire after interception

Why does this fail silently? Because CORS failures happen after the network request succeeded. The server returned 200 with a body. The browser then refused to hand that body to JavaScript. Server logs show success, Playwright’s request log shows success, and the only trace is a console error on the page. If your agent does not collect console messages, the failure is invisible. That is the whole reason this class of regression survives smoke tests.

There is a second silent mode, which is the server side. Many backends choose their Access-Control-Allow-Origin value by reading the incoming Origin. If the header goes missing, becomes null, or changes casing or port, the server falls into a default branch. That branch often returns a valid response with no CORS headers, or a 403 with an HTML body that your tool wrapper then tries to parse as JSON. Either way the failure appears one layer away from the cause.

A canary you can run in CI

This is the test I wish I had written two years earlier. It starts two local servers on different ports, makes the page issue a handful of request shapes across origins, and records what the “API” server actually received. The recorded log is compared to a committed snapshot, per browser project. When Playwright or a browser changes what goes on the wire, the diff shows up in the pull request that bumps the version.

// tests/cors-canary.spec.ts
import { test, expect } from '@playwright/test';
import http from 'node:http';
import { AddressInfo } from 'node:net';

type Seen = { method: string; path: string; origin: string | null; site: string | null; acrm: string | null };

function startApi(seen: Seen[]) {
  const server = http.createServer((req, res) => {
    const origin = (req.headers['origin'] as string) ?? null;
    seen.push({
      method: req.method!,
      path: req.url!,
      origin,
      site: (req.headers['sec-fetch-site'] as string) ?? null,
      acrm: (req.headers['access-control-request-method'] as string) ?? null,
    });
    if (origin) {
      res.setHeader('Access-Control-Allow-Origin', origin);
      res.setHeader('Access-Control-Allow-Headers', 'content-type,x-agent-token');
      res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT');
    }
    if (req.method === 'OPTIONS') { res.statusCode = 204; return res.end(); }
    res.setHeader('content-type', 'application/json');
    res.end(JSON.stringify({ ok: true }));
  });
  return new Promise<http.Server>(r => server.listen(0, '127.0.0.1', () => r(server)));
}

function startPage() {
  const server = http.createServer((_req, res) => {
    res.setHeader('content-type', 'text/html');
    res.end('<!doctype html><title>canary</title>');
  });
  return new Promise<http.Server>(r => server.listen(0, '127.0.0.1', () => r(server)));
}

test('origin header and preflight shape are stable', async ({ page, browserName }) => {
  const seen: Seen[] = [];
  const api = await startApi(seen);
  const web = await startPage();
  const apiPort = (api.address() as AddressInfo).port;
  const webPort = (web.address() as AddressInfo).port;
  const apiBase = `http://127.0.0.1:${apiPort}`;

  await page.goto(`http://127.0.0.1:${webPort}/`);

  const results = await page.evaluate(async (base) => {
    const out: Record<string, string> = {};
    const attempt = async (name: string, init?: RequestInit) => {
      try { const r = await fetch(`${base}/${name}`, init); out[name] = `status:${r.status}`; }
      catch (e) { out[name] = `error:${(e as Error).name}`; }
    };
    await attempt('simple-get');
    await attempt('json-post', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' });
    await attempt('custom-header', { headers: { 'x-agent-token': 't' } });
    await attempt('no-cors-get', { mode: 'no-cors' });
    return out;
  }, apiBase);

  // normalise the dynamic port so the snapshot is stable
  const normalised = seen.map(s => ({ ...s, origin: s.origin?.replace(/:\d+$/, ':PORT') ?? null }));
  expect({ results, normalised }).toMatchSnapshot(`cors-wire-${browserName}.json`);

  api.close(); web.close();
});

Run it with the project matrix set to all three browsers, and commit the snapshots generated on your current pinned version. When you bump Playwright in a branch, the snapshot comparison fails if any of these change: whether Origin is present on the no-cors GET, whether the preflight OPTIONS shows up for the custom header, what Sec-Fetch-Site says, or whether a request that used to succeed now throws. Review the diff by hand. A changed snapshot is not automatically a bug. It is automatically something a human should look at before an agent depends on it.

Add a second variant of the same test with a page.route('**/*', route => route.continue()) handler installed. That one exists to catch the interception layer specifically, and the difference between the two snapshots is your answer to “is it Playwright or is it the browser”.

Worked examples

These three examples are constructed from the failure shapes I have hit or that follow directly from the specification. They are illustrations to make the mechanism concrete, not measured results from a benchmark, and the numbers are arithmetic on the setups I describe.

Example 1: the allowlist that only matched one spelling

Say an internal tools API at https://tools.example.internal allowlists exactly two origins, https://dashboard.example.internal and https://staging-dashboard.example.internal, and returns CORS headers only for those. Your agent loads the dashboard, and a page script calls the tools API with a custom x-agent-token header. That is a non-simple request, so there is one OPTIONS preflight and one real request per call.

An agent run that makes 40 tool calls therefore produces 80 requests on the wire, 40 preflights and 40 actual calls, assuming the preflight is not cached. In practice browsers cache preflights for a limited time, so your count will be lower and will depend on Access-Control-Max-Age and browser caps. Now suppose a browser bump changes how the preflight cache is keyed, or the harness starts the page from about:blank plus setContent() so the origin becomes null. The allowlist no longer matches, the server answers the preflight without CORS headers, and all 40 calls fail while the server log shows 40 successful OPTIONS responses with status 204. If your wrapper catches TypeError and returns [], the agent sees “no results” 40 times.

The counter-measure is a startup assertion in the agent itself: before the first real task, make one call to a health endpoint through the exact path the tools use, and fail the run if it throws. The canary test above finds the version regression in CI, and the startup check finds the environment regression in production.

Example 2: route.fulfill hiding a real CORS failure

A common agent pattern is to stub a slow or paid third-party endpoint. You register page.route('https://api.vendor.example/**', route => route.fulfill({ json: cached })). The page makes a cross-origin request, your handler fulfils it, and everything works. You have effectively tested your stub, not the vendor.

Now you remove the stub for a live run and the call fails with a CORS error because the vendor never allowed your page origin. In the mock path it worked because fulfilment satisfies the browser’s CORS check on your behalf. Whether fulfilled cross-origin responses get permissive CORS headers added, and exactly which ones, is behaviour I would verify per version rather than assume. If a Playwright upgrade changes what is added, a test that passes today can start failing, or worse, a stub-based test can keep passing while the live path stays broken. A suite that runs 100 percent on stubs has told you nothing about CORS at all.

The counter-measure is to keep at least one test per external origin that runs unstubbed against a real endpoint you are allowed to call, and assert on the response, not only on “no exception”.

Example 3: route.fetch with and without the Origin

Consider a handler that adds an auth header: it calls route.fetch({ headers: { ...route.request().headers(), authorization: token } }), then route.fulfill({ response }). route.fetch() goes through APIRequestContext, which is Node-side and not subject to CORS. So the request to the real server may or may not carry an Origin depending on which headers you pass through, and the server’s CORS decision is made against whatever it received.

Take a server that picks Access-Control-Allow-Origin from the request’s Origin. If your handler forwards the browser’s original headers, the server sees the page origin and replies with a matching allow header, and the browser is happy. If a version change alters which headers are forwarded by default, or you build the header set from scratch, the server sees no Origin, omits CORS headers, and the browser blocks the fulfilled response. Four lines of handler code, one fewer header on the wire, zero errors in Node. The only symptom is a console error in the page.

The counter-measure is to set the headers you depend on explicitly instead of relying on defaults, and to put the handler path in the canary test’s second variant so a change in default carry-over shows up in the snapshot.

Edge cases and failure modes

  • opaque origins: page.setContent(), file://, data: and sandboxed iframes send Origin: null. A server that allows a specific origin will reject these, and a server that allows null is a security hole. If your agent builds pages from strings, serve them from a real local origin instead. A tiny static server on 127.0.0.1 costs nothing and removes a whole class of version-sensitive behaviour.
  • preflight caching hides regressions: browsers cache successful preflights, so a test that makes one call can pass while a fresh browser profile in production fails every call. Playwright gives you a fresh context per test by default, which is good for catching this, but long-lived persistent contexts in agent fleets can mask it for hours. Test with a clean profile, and log when the profile was created.
  • the failure is only visible in the console: wire-level logs show success. Subscribe to page.on('console') and page.on('requestfailed') in every agent run and treat a CORS-shaped message as a tool error with a clear message. A fetch rejection should never be converted into an empty result. Return an explicit error object so the model cannot mistake absence for emptiness.
  • launch flags that mask the problem: --disable-web-security and similar flags make CORS errors disappear in development and then come back in production where the flag is absent. Flags also change between browser versions in whether they still work. I keep them out of anything that is meant to resemble production, and only use them for narrowly scoped debugging sessions, never for the agent itself.
  • private network and cookie policy changes: Chromium has been tightening what pages may request from local and private network addresses, and cookie handling in cross-site contexts, over several releases. Because those ship in the bundled browser, a Playwright bump can pull them in. These are covered by the same snapshot idea: add a request to a private-network address to the canary if your agent talks to one.
  • the fleet runs mixed versions: during a rolling upgrade, half the workers run the old version and half the new one, and the same task behaves differently depending on which worker picked it up. Tag every run with the Playwright version and browser build, and make that visible in your traces. I wrote about the orchestration side of this in the best ways to orchestrate many Playwright browser sessions for a fleet of agents, and version tagging is the first thing I would add to any of those setups.

A last pitfall that is not strictly CORS: a Playwright upgrade can also change startup timing, and a request that depends on a page being ready can fail for reasons that look like network problems. If an upgrade makes runs time out at startup rather than fail on a specific request, that is a different diagnosis, and why a browser agent that works locally can time out 30 seconds into startup covers it. Rule that out first, so you do not chase CORS ghosts.

What we learned in production

The biggest change in how I work is that I no longer treat a Playwright bump as a dependency bump. It is a browser upgrade with a thin layer of library code on top. My upgrade checklist, as of October 2026, is short: read the release notes for the bundled browser versions, run the canary on all three browsers against both the old and new version, diff the snapshots, and only then run the real agent smoke tests. The canary takes seconds to run. The cost of not having it was a few afternoons of reading server logs that said everything was fine.

The second lesson is about where the guard belongs. CI catches version regressions, but it cannot catch a production environment whose origin, proxy, or allowlist differs from the test setup. So I keep both: the snapshot test for the library and browser, and a first-call health check inside the agent for the environment. If either fails, the run stops with a plain error message. I would rather have a loud failed run than a quiet wrong answer, and with agents the second one is much more expensive because someone acts on it later.

Written by Xavier Fok

References and further reading

More deep-dives on browser agents are in the blog index.

disclosure: this article may contain affiliate links. if you buy through them we may earn a commission at no extra cost to you. verdicts are independent of payouts. last reviewed by Xavier Fok on 2026-10-11.

free download
Why did my agent get blocked? A triage checklist

The checks we run, in order, when a browser or phone agent starts failing: network, fingerprint, behaviour, account. Leave your email and we will also tell you when we publish a new field note, a few times a month at most.

from the team behind this site
A Singapore mobile IP for browser agents

Singapore Mobile Proxy runs real mobile IPs on SingTel, StarHub and M1, with sticky sessions so one task keeps one IP. Singapore only: a fit for SEA or location-agnostic work, the wrong tool if you need a US IP.

see plans →
from the team behind this site
A real Android phone for phone-use agents

cloudf.one hosts real Android phones in Singapore on dedicated hardware, each with a persistent Singapore mobile IP. For agents that need an actual device and a stable carrier identity.

get a phone →
read on
More from The Agent Ops Report

Blocks, sessions, retries, traces, cost per task and phone-use agents. Browse all articles →