Playwright takes a proxy as an object, not as a URL. Pass proxy with server, username and password to launch() for the whole browser, or to newContext() for one context. Credentials written inside the server URL are ignored, which is why most broken setups fail at the first request.
The Playwright proxy object, at launch and per context
There is only one shape to learn. The Playwright docs define the same four fields on browserType.launch() and on browser.newContext(): server, an optional comma-separated bypass list, and an optional username and password pair.
// Node.js — one proxy for the whole browser
const { chromium } = require('playwright');
const browser = await chromium.launch({
proxy: {
server: 'http://gw.example.com:8080',
username: 'sub-user',
password: 'secret',
},
});
const page = await browser.newPage();
await page.goto('https://api.ipify.org?format=json');
console.log(await page.textContent('body'));
await browser.close();
Python takes a dictionary with the same keys:
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser = p.chromium.launch(proxy={
"server": "http://gw.example.com:8080",
"username": "sub-user",
"password": "secret",
})
page = browser.new_page()
page.goto("https://api.ipify.org?format=json")
print(page.text_content("body"))
browser.close()
The server value accepts an HTTP, HTTPS or SOCKSv5 endpoint. A bare host:port with no scheme is treated as an HTTP proxy. If the echoed address is the exit rather than your own, the route is live; the proxy tester performs the same check without any code when you want a second opinion on the credentials themselves.
Playwright proxy authentication: the fields, not the URL
The single most common failure is inherited from curl and requests habits: writing the credentials into the endpoint. Playwright parses server as an address, so a user and password embedded in it are silently dropped and the proxy answers with a 407 that surfaces as a blank page. Most teams keep one environment variable and split it at startup:
PROXY_URL=http://sub-user:secret@gw.example.com:8080
function toPlaywrightProxy(raw) {
const u = new URL(raw);
return {
server: u.protocol + '//' + u.host, // scheme + host + port only
username: decodeURIComponent(u.username),
password: decodeURIComponent(u.password),
};
}
const browser = await chromium.launch({ proxy: toPlaywrightProxy(process.env.PROXY_URL) });
Two details decide whether this works. The decodeURIComponent call matters because gateway passwords often contain reserved characters that were percent-encoded into the URL. And the documented scope of username and password is HTTP and HTTPS proxies: a socks5:// server with credentials is not a supported combination in Chromium, which is why authenticated SOCKS5 endpoints fail where the same credentials work over HTTP. If you need SOCKS specifically, use an endpoint that authorises by IP allowlist instead of by password; both styles are available on the SOCKS5 network. A live 407 from an HTTP endpoint has its own diagnosis path in our 407 Proxy Authentication Required guide.
Per-context proxies: several exits inside one browser
A launch-level proxy applies to every page the browser opens. A context-level proxy applies only to that context, and contexts are cheap, which makes the context the natural unit of rotation:
const browser = await chromium.launch();
for (const job of jobs) {
const context = await browser.newContext({
proxy: { server: 'http://gw.example.com:8080', username: job.session, password: 'secret' },
userAgent: job.userAgent,
});
const page = await context.newPage();
await page.goto(job.url, { waitUntil: 'domcontentloaded' });
await handle(page);
await context.close(); // cookies, storage and the exit all go away together
}
Closing the context discards cookies and local storage along with the exit, so the next job starts clean. Two rotation styles then work with this loop. A rotating endpoint hands you a different IP per connection with no code changes at all, which suits page-at-a-time crawling. A sticky session, selected through the username, holds one IP for the length of a multi-step flow such as a search, a filter and a detail page. Choosing between them is a question about the target, not about Playwright; the trade-off is set out in our Playwright scraping guide.
What one page load actually pulls through the exit
Every guide stops at the config object, which hides the part that shows up on the invoice. A proxy meters bytes, and a browser does not fetch a page: it fetches a document and then everything the document asks for. On 11 September 2026 we loaded seven pages once each in a Chromium browser with no extensions and an empty cache for each origin, and read the request count and the wire bytes straight from the Performance Resource Timing API.
| Page | Requests | Document on the wire | Whole load | Multiple |
|---|---|---|---|---|
| Hacker News front page | 6 | 5.9 KB | 11.5 KB | 2.0x |
| Playwright docs, Network page | 15 | 13.2 KB | 260 KB | 19.7x |
| This site, quanticdata.io home | 25 | 15.5 KB | 317 KB | 20.4x |
| Wikipedia, Web scraping | 26 | 45.8 KB | 402 KB | 8.8x |
| Stack Overflow question page | 48 | 56.4 KB | 718 KB | 12.7x |
| BBC News front page | 72 | 65.8 KB | 1.24 MB | 19.4x |
| GitHub repository page | 161 | 57.8 KB | 1.94 MB | 34.5x |
Across the seven pages, 353 requests moved 4.86 MB while the documents themselves accounted for 260 KB. The median page cost 19.4 times its own HTML, and the spread is the interesting part: a plain server-rendered page stayed near parity, an application shell multiplied it by more than thirty. One honest caveat, and it points the same way: resources served cross-origin without a Timing-Allow-Origin header report zero bytes to the timing API, so every figure above is a floor rather than a total.
The bill: metered bandwidth against pay per success
Turn the measurement into money at published rates. Ten thousand loads of the GitHub-sized page move roughly 20.4 GB, which is about $16.30 on residential Basic at $0.80/GB. Fetching only the document for those same ten thousand URLs moves 0.59 GB, about $0.47. The browser, in other words, is not a small surcharge on the proxy bill. It is the bill.
That is the honest case for keeping Playwright where it earns its keep, and for sizing the plan before the first run rather than after it, which is what this sizing walkthrough is for. Where the page does not truly need a browser, the scraping API charges per successful page instead of per gigabyte, from $0.0002 per page and $0.001 with JavaScript rendering, and failures are not billed at all. The same ten thousand rendered pages land at $10 with the browser fleet included.
Trim the page before it costs you
When you keep the browser, stop paying for pixels you never read. Route interception drops whole resource classes before they reach the exit:
await context.route('**/*', route => {
const type = route.request().resourceType();
if (type === 'image' || type === 'media' || type === 'font') return route.abort();
return route.continue();
});
On the pages measured above, images, fonts and media are the bulk of the difference between the document and the whole load. Two cautions. Blocking stylesheets as well will break any selector that depends on layout or visibility, so leave stylesheet alone unless your extraction is purely structural. And an empty image cache is itself a signal on sites that score behaviour rather than headers, which is the boundary described in the stealth write-up: a browser that downloads nothing does not look like a browser.
When the proxy is set and nothing works
Chromium reports proxy failures with numbered network errors, and each number narrows the cause to one layer. The values come straight from the Chromium error list.
| Error | Number | What it means |
|---|---|---|
| ERR_PROXY_CONNECTION_FAILED | -130 | The proxy host did not resolve, or the socket to it never opened. Wrong host, wrong port, firewall. |
| ERR_TUNNEL_CONNECTION_FAILED | -111 | You reached the proxy, but its CONNECT to the target failed. Usually credentials, an expired plan, or a blocked destination. |
| ERR_NO_SUPPORTED_PROXIES | -336 | Nothing in the proxy list is usable by this browser. A scheme it cannot speak, typically an authenticated SOCKS endpoint. |
| ERR_UNEXPECTED_PROXY_AUTH | -323 | A 407 arrived on a request that was not sent to a proxy at all, so the proxy settings are not applying to that request. |
| ERR_EMPTY_RESPONSE | -324 | The connection closed with no data. Often the target dropping the exit rather than a proxy fault. |
The tunnel failure is the one that swallows the most hours, and it has its own checklist in the ERR_TUNNEL_CONNECTION_FAILED guide. Two further traps have nothing to do with your configuration being wrong:
- Local addresses skip the proxy. Chromium applies implicit bypass rules to localhost and other loopback addresses, so a request to your own test server never reaches the exit. Subtracting those rules requires the explicit
<-loopback>entry in the bypass list, and it is deliberate behaviour, not a Playwright bug. - A context proxy beats the launch proxy. If the exit IP does not change when you expect it to, check whether a
newContext()call further down the file is setting its ownproxy, or whether the target domain matches an entry inbypass. The bypass list is comma separated and a leading dot matches subdomains.
Finally, confirm the exit before blaming the site. Load an IP echo in the same context you are about to scrape with, not in a separate throwaway browser. A proxy that authenticates correctly in one context and not in another is nearly always a configuration that was applied at the wrong level.