A Playwright proxy is configured with a proxy object of the form { server, username, password, bypass }, passed either to chromium.launch() for the whole browser or to browser.newContext() for one isolated session. Launch-level configuration is simplest; per-context configuration lets one browser process hold many sessions, each with its own exit IP, cookies and storage. Playwright answers the proxy's authentication challenge for you, which makes it easier to proxy than raw Chrome.
This guide covers both levels in Node.js and Python, then the details that matter in production: SOCKS5 limitations, sticky sessions, bandwidth, timeouts and checking the exit IP. The examples read the connection string from PROXY_URL, shaped like http://USERNAME:[email protected]:PORT, and split it into the fields Playwright expects. Your dashboard generates the exact string for the country and session mode you choose.
Launch-level proxy: one exit for the whole browser
Passing proxy to launch() routes every context and page in that browser through the same proxy configuration.
import { chromium } from 'playwright';
function proxySettings(proxyUrl) {
const url = new URL(proxyUrl);
return {
server: `${url.protocol}//${url.host}`,
username: decodeURIComponent(url.username),
password: decodeURIComponent(url.password),
};
}
const browser = await chromium.launch({ proxy: proxySettings(process.env.PROXY_URL) });
const page = await browser.newPage();
await page.goto('https://api.ipify.org?format=json', { timeout: 30_000 });
console.log(await page.textContent('body'));
await browser.close();
The helper strips the credentials out of the URL and URL-decodes them, because server should contain only the scheme, host and port. The decodeURIComponent calls matter when a password contains characters such as @ or :, which appear percent-encoded in the connection string.
The Python equivalent uses the same field names:
import os
from urllib.parse import unquote, urlsplit
from playwright.sync_api import sync_playwright
def proxy_settings(proxy_url: str) -> dict:
parts = urlsplit(proxy_url)
return {
"server": f"{parts.scheme}://{parts.hostname}:{parts.port}",
"username": unquote(parts.username or ""),
"password": unquote(parts.password or ""),
}
with sync_playwright() as p:
browser = p.chromium.launch(proxy=proxy_settings(os.environ["PROXY_URL"]))
page = browser.new_page()
page.goto("https://api.ipify.org?format=json", timeout=30_000)
print(page.inner_text("body"))
browser.close()
Launch-level configuration suits a job where every page should share one identity: a single sticky session, or a single dedicated ISP address. The full option reference is in Playwright's network documentation.
Per-context proxies: one Playwright proxy session per context
A browser context is Playwright's unit of isolation: its own cookies, local storage, cache and permissions. Giving each context its own proxy turns it into a complete, separate identity, and a single browser process can run many of them.
import { chromium } from 'playwright';
const BLOCKED_TYPES = new Set(['image', 'font', 'media']);
function proxySettings(proxyUrl) {
const url = new URL(proxyUrl);
return {
server: `${url.protocol}//${url.host}`,
username: decodeURIComponent(url.username),
password: decodeURIComponent(url.password),
};
}
const sessionUrls = process.env.PROXY_URLS.split(/\s+/).filter(Boolean);
const browser = await chromium.launch();
try {
for (const sessionUrl of sessionUrls) {
const context = await browser.newContext({ proxy: proxySettings(sessionUrl) });
await context.route('**/*', (route) =>
BLOCKED_TYPES.has(route.request().resourceType()) ? route.abort() : route.continue(),
);
const page = await context.newPage();
await page.goto('https://api.ipify.org?format=json', { timeout: 30_000 });
console.log(await page.textContent('body'));
await context.close();
}
} finally {
await browser.close();
}
PROXY_URLS here is a whitespace-separated list of connection strings, for example several sticky sessions generated in your dashboard, or one entry per dedicated ISP or datacenter address. In Python the call is browser.new_context(proxy=proxy_settings(url)) with the same dictionary as above.
Why browsers want sticky sessions
A browser opens many connections to load one page: the document, scripts, stylesheets, API calls, often across several hosts. If the endpoint rotates per connection, those requests can leave from different addresses mid-page, and many sites treat a session whose IP changes between the HTML and its XHR calls as suspicious. For browser automation, the reliable pattern is:
- One context per identity.
- One sticky session per context.
- A context lifetime shorter than the sticky window. On ProxyForge, residential sessions hold for up to 60 minutes and mobile for up to 30; ISP and datacenter addresses are static and have no window.
- Close the context and start a new one, with a new session, when the identity is done or the target starts refusing it.
Rotating endpoints still have a place with browsers: one-page-per-context crawls where each context loads a single URL and closes. Rotating vs sticky proxies covers how to choose a session length for a given target.
Authentication and SOCKS5 limitations
Playwright supports two ways to authenticate to a proxy:
- Username and password through the
usernameandpasswordfields. Playwright answers the gateway's407 Proxy Authentication Requiredchallenge itself, which is the main reason proxying is simpler here than in Puppeteer or Selenium. - IP allowlisting, where the gateway accepts connections from your registered egress address and you pass only
server. This avoids sending credentials at all, and it is the only option in the SOCKS5 case below.
A Playwright proxy over SOCKS5 works, but not with credentials. As of Playwright 1.62, launching with a socks5:// server plus a username or password fails immediately with Browser does not support socks5 proxy authentication. If you need SOCKS5 in a browser, allowlist the machine's egress IP and pass only the server:
import { chromium } from 'playwright';
const browser = await chromium.launch({
proxy: { server: process.env.SOCKS_PROXY_SERVER },
});
Here SOCKS_PROXY_SERVER holds socks5://gateway.proxyforge.io:PORT with no credentials. For most browser work, an HTTP proxy with credentials is simpler and loses nothing: HTTPS pages are carried inside a CONNECT tunnel either way. The trade-offs between the two authentication modes are covered in IP allowlist vs username and password.
Bypassing the proxy for some hosts
bypass takes a comma-separated list of domains that should connect directly, for example 'localhost,.internal.example.com'. A leading dot matches subdomains. Use it for your own services, test fixtures and telemetry, which have no reason to spend proxy bandwidth. Everything not listed goes through the proxy.
Saving bandwidth by blocking images, fonts and media
Residential and mobile lines are billed per GB, and a modern page can weigh several megabytes, most of it images, fonts and video. The per-context example above aborts those resource types with context.route(). The Python version:
BLOCKED_TYPES = {"image", "font", "media"}
def block_heavy_assets(route):
if route.request.resource_type in BLOCKED_TYPES:
return route.abort()
return route.continue_()
context.route("**/*", block_heavy_assets)
Two caveats before you enable this everywhere:
- Routing disables the HTTP cache. Playwright's documentation notes that enabling routing turns off the browser cache for the page or context. On a site with heavy, cacheable JavaScript, fetching scripts again on every navigation can cost more than the images you blocked. Measure transferred bytes both ways on your actual targets.
- Some pages depend on what you block. Lazy-loaded content, image-based layout checks and some bot-detection scripts behave differently when images or fonts never arrive. If data goes missing, unblock one resource type at a time.
Blocking third-party hosts such as analytics and ad networks by URL pattern often saves as much as blocking images and has fewer side effects.
Timeouts, retries and verifying the exit IP
Set timeouts deliberately. Playwright's default navigation timeout is 30 seconds, which is reasonable for proxied traffic; setting it explicitly with page.goto(url, { timeout }) or context.setDefaultNavigationTimeout() makes the budget visible in code review.
Proxy failures surface as navigation errors rather than HTTP statuses. In Chromium, net::ERR_PROXY_CONNECTION_FAILED means the gateway could not be reached, and net::ERR_TUNNEL_CONNECTION_FAILED means the proxy accepted the connection but could not open the tunnel to the target. Wrong credentials are harder to spot: in our tests with Chromium, Playwright 1.62 did not raise an authentication error, and the navigation simply ran until its timeout. If every navigation in a fresh context times out, check the credentials before blaming the target. A sensible retry policy:
- On a connection or tunnel error, retry once after a short delay; if it repeats, stop and check the connection string, credentials and allowlisting rather than retrying further.
- On a timeout or a target-side block (403, 429, a challenge page), close the context and retry in a fresh context with a new session. Retrying in the same context keeps the same exit and the same cookies that were just refused.
- Cap total attempts per URL, and record which session served each attempt.
Proxy 403 and 429 errors explains how to tell a target-side block from a proxy problem.
To verify the exit, navigate a page in the same context to an IP echo service, as the examples above do, and log the address with the job. context.request.get() also goes through the context's proxy and skips rendering, so it is a cheaper check. Checking from your shell or a different HTTP client proves nothing about the context that will do the work.
Concurrency
One browser process with several contexts is lighter than several browsers, but each context still costs memory, and each page costs more. Bound concurrency with a worker pool sized from measurements on your real targets, and restart browser processes periodically on long runs. The proxy is rarely the bottleneck; the machine running the browsers usually is.
Which proxy line fits a Playwright workload
Browsers download everything a page references, so a Playwright proxy carries far more bandwidth per page than an HTTP client does. That shifts the economics toward per-address lines for heavy, repetitive work.
| Workload | Line | Notes |
|---|---|---|
| Consumer sites that score IP reputation, one identity per context | Residential | Sticky up to 60 minutes; block heavy assets to control per-GB cost |
| Long-lived logged-in accounts, the same address every day | ISP | Static, dedicated, billed per address, so page weight does not change the bill |
| Internal QA, monitoring your own properties, tolerant targets | Datacenter | Dedicated addresses from our own ASN |
| Mobile web flows and carrier-specific content | Mobile | Sticky up to 30 minutes; pair with a mobile device profile |
Proxy pricing per GB vs per IP walks through the break-even calculation for browser workloads.
Running Playwright on ProxyForge
Every ProxyForge line accepts HTTP, HTTPS and SOCKS5, with username and password or an IP allowlist, so both the launch-level and per-context patterns above work unchanged across lines. The dashboard generates the connection string for each country and session mode. Blocking images and fonts, as shown above, is the largest single control on what a browser fleet spends on per-GB lines.
Residential and mobile are billed per GB and ISP and datacenter per address, pay-as-you-go from a prepaid wallet; current rates are on the pricing page. If your Playwright fleet already runs on another provider, the migration process mirrors your existing session syntax so you can run both side by side before moving traffic.