Playwright Mobile Proxy Configuration Guide
Key takeaways
- Playwright takes credentials directly in the
proxyoption —{ server, username, password }— at launch. There is no separate authentication call, which is the main thing it does more cleanly than Puppeteer. browser.newContext({ proxy })gives you a different exit per context, so one browser process can hold several independent identities, each with its own cookies, storage and IP.- On some platforms Chromium needs a proxy set at launch for per-context proxies to take effect. The documented pattern is a placeholder server at launch that every context then overrides.
- Chromium resolves DNS locally when handed a SOCKS5 proxy. Your resolver sees every hostname while traffic exits elsewhere — a leak that also causes CDN misroutes. An HTTP proxy avoids it entirely:
CONNECTcarries the hostname. - SOCKS5 with a username and password is not supported in Chromium. Playwright surfaces this as an explicit error rather than failing quietly. Use the HTTP proxy port when credentials are required.
- Concurrency is the thing developers get wrong. A mobile exit is one handset with one radio and one uplink, so
workersand context count have to come down. That is a property of the device, not a limitation of Playwright. - Rotation is a real radio re-attach costing roughly seven seconds. Actions in flight fail; the correct response is a short backoff and a retry, never an immediate one.
Playwright has the cleanest proxy API of the browser automation tools, so the setup genuinely is a few lines and the interesting problems are elsewhere: which level to set the proxy at, what Chromium does with DNS when you hand it SOCKS5, and how to size worker and context counts when everything leaves through one physical handset.
The setup, at browser level
Pass a proxy object to launch() with the server, username and password. Playwright answers the proxy's authentication challenge itself, so there is nothing else to wire up. Every context and page created from that browser exits through the same address, which is the correct default when you want one stable identity.
import { chromium } from 'playwright';
const browser = await chromium.launch({
headless: true,
proxy: {
server: 'http://proxy.example.com:30001',
username: 'your_http_user',
password: 'your_http_pass',
// bypass: 'localhost,127.0.0.1',
},
});
const context = await browser.newContext();
const page = await context.newPage();
page.setDefaultNavigationTimeout(90_000);
const res = await page.goto('https://api.ipify.org', {
waitUntil: 'domcontentloaded',
});
console.log(res.status(), await page.textContent('body'));
await browser.close();
Credentials are taken literally — no URL parsing, no percent-decoding, so a password containing @, : or % needs no escaping, removing one of the more annoying causes of phantom 407 errors. bypass takes a comma-separated list of hosts that skip the proxy.
Verify the exit address on the first navigation, every time. A proxy that is configured but not applied looks identical to one that is working, until something downstream disagrees with you about geography.
Context-level proxies: several exits in one browser
browser.newContext({ proxy }) overrides whatever the browser was launched with. A context already owns its cookies, storage and cache, so a distinct proxy makes it a genuinely separate identity — same process, different address, no shared state. This is the capability Playwright has that the raw Chromium command line does not.
const browser = await chromium.launch({
headless: true,
// Placeholder: on some platforms Chromium needs a proxy at launch
// for per-context proxies to take effect. Every context overrides it.
proxy: { server: 'http://per-context' },
});
async function contextForDevice({ server, username, password }) {
const ctx = await browser.newContext({
proxy: { server, username, password },
viewport: { width: 390, height: 844 },
});
ctx.setDefaultNavigationTimeout(90_000);
return ctx;
}
The placeholder at launch is the part people trip over. Playwright's documentation notes that on some platforms Chromium needs a proxy specified at launch for context-level proxies to work at all; when every context overrides it, the launch value is never used and can be any string. If per-context proxies appear ignored, check that first.
One browser can then run one device per context. But note what you are building: multiple parallel identities from one automation host is a pattern in itself, and if each carries a login, fewer and longer-lived sessions serve you better — the whole of dedicated vs rotating proxies.
SOCKS5, authentication, and where DNS resolves
Playwright accepts socks5:// in the server field, and unauthenticated SOCKS5 works. Two Chromium behaviours then apply and both surprise people: SOCKS5 username and password authentication is not supported, and Chromium resolves destination hostnames locally instead of passing them to the proxy.
The authentication limit is structural. SOCKS5 authenticates during its own handshake and the browser has no path for that; Playwright reports an explicit socks5-authentication error rather than failing ambiguously. If your provider requires credentials on the SOCKS5 port, and some issue separate pairs for SOCKS5 and HTTP — use the HTTP port.
The DNS behaviour costs more and is noticed less. Chromium performs the lookup itself and hands the proxy an address, so your own resolver sees every domain you visit while the traffic exits elsewhere. Worse for scraping, large sites answer DNS according to where the query came from, so you get a CDN edge chosen for your real location while the connection arrives from a carrier network somewhere else — rarely a clean failure, just slow connects and content served for a region your visible IP does not claim.
| Configuration | Who resolves the hostname | Leaks your resolver |
|---|---|---|
http:// proxy | The proxy — CONNECT carries the name | No |
socks5:// in Chromium | Your machine, by default | Yes |
socks5:// in Firefox with remote DNS enabled | The proxy | No |
The practical answer for Chromium is the HTTP proxy port, where there is no resolution mode to get wrong. On a mobile proxy the lookup then happens on the phone, over the carrier's resolvers, matching the carrier IP the destination sees. For Firefox, firefoxUserPrefs at launch is where the remote-DNS preference lives.
If a hostname fails through SOCKS5 while a literal IP succeeds, that is a resolution problem, not a connectivity problem. The instant-failure variant is a device-side stale resolver; background in SOCKS5 vs HTTP proxies.
Concurrency: workers, contexts and one radio
This is where mobile proxies stop behaving like the datacenter pools these defaults assume. A mobile exit is one handset with one radio and one uplink. Workers and contexts running in parallel do not add capacity — they divide a fixed resource, and past a point they degrade the device itself. Nothing in Playwright changes that.
Three mechanisms stack, and they present differently enough to separate.
The uplink is the ceiling. Roughly 2-3 MB/s per device, set by the cellular upload path — everything the destination returns is pushed up from the phone before it reaches you, and carriers allocate far less capacity upward than downward.
Heavy fan-out can wedge the device. Concurrent connections share one multiplexed tunnel, and under very heavy fan-out that multiplexer can wedge: the phone goes quiet while still appearing connected, and a watchdog recovers it in about three minutes. The trigger is simultaneous streams rather than request rate, so cutting workers beats adding delays.
Parallel bursts look like automation. Twenty simultaneous connections from a consumer carrier address is not something a phone does — a behavioural signal on top of everything else the destination scores, and a dependable way to collect 429s.
For the test runner, start at one worker per device:
// playwright.config.js
export default {
workers: 1, // one worker per device; scale devices, not workers
retries: 2,
timeout: 120_000,
use: {
proxy: {
server: 'http://proxy.example.com:30001',
username: 'your_http_user',
password: 'your_http_pass',
},
navigationTimeout: 90_000,
actionTimeout: 30_000,
},
};
Cutting bytes per page matters as much as cutting workers, because asset loading is most of the traffic:
await context.route('**/*', (route) => {
const type = route.request().resourceType();
if (type === 'image' || type === 'media' || type === 'font') {
return route.abort();
}
return route.continue();
});
Aborting images, media and fonts removes the bulk of a typical page load. On a constrained uplink that is the highest-leverage change available, and it cuts the simultaneous streams the device has to multiplex. More in why mobile proxies are slow.
Retry, Retry-After, and the seven-second rotation gap
Two different waits show up in browser automation and conflating them causes real damage. A 429 tells you exactly when to return, in a header most code discards. A rotation is a physical event taking about seven seconds whatever your timeouts say. Both want a backoff; neither wants an immediate retry.
page.goto() returns a response, so the header is readable directly, lowercased.
const MAX_WAIT_MS = 120_000;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
function retryAfterMs(res) {
const raw = res?.headers()?.['retry-after'];
if (!raw) return null;
if (/^\d+$/.test(raw.trim())) return Number(raw.trim()) * 1000;
const when = Date.parse(raw); // the HTTP-date form
return Number.isNaN(when) ? null : Math.max(0, when - Date.now());
}
export async function gotoWithBackoff(page, url, attempts = 4) {
for (let i = 0; i < attempts; i++) {
try {
const res = await page.goto(url, { waitUntil: 'domcontentloaded' });
if (res && (res.status() === 429 || res.status() === 503)) {
const wait = retryAfterMs(res) ?? Math.min(MAX_WAIT_MS, 2000 * 2 ** i);
await sleep(Math.min(wait, MAX_WAIT_MS));
continue;
}
return res;
} catch (err) {
// Also covers the rotation window: the radio is re-attaching and
// nothing will connect for several seconds.
if (i === attempts - 1) throw err;
await sleep(Math.min(MAX_WAIT_MS, 3000 * 2 ** i));
}
}
}
Retry-After has two legal formats — a delay in seconds or an HTTP-date, and a parser that only handles integers breaks on the second. Honouring it is the most effective single change against a 429.
Rotation deserves its own allowance: an airplane-mode cycle on a real handset — detach, re-attach, authenticate, rebuild the bearer — about seven seconds, almost all of it carrier-side work no provider can engineer away. A suite that rotates between runs should expect the first navigation afterwards to fail, wait, and retry. If a rotation returns success in roughly two seconds, nothing happened; that is a wedged radio.
Proxying requests without a browser page
Playwright's API request context takes the same proxy object, which helps when part of your work is plain HTTP and needs no rendered page. It is far cheaper than a browser for verification steps and API calls, and it exits through the same address so the session stays consistent.
import { request } from 'playwright';
const api = await request.newContext({
proxy: {
server: 'http://proxy.example.com:30001',
username: 'your_http_user',
password: 'your_http_pass',
},
timeout: 60_000,
});
const res = await api.get('https://api.ipify.org');
console.log(res.status(), await res.text());
await api.dispose();
A browser context also exposes a request object inheriting its proxy and cookies, which is the right tool when an API call needs the session the page established. Mixing a proxied page with unproxied direct calls is a common self-inflicted inconsistency — the site sees one identity for the page and another for the API traffic.
Symptom, cause, fix
Playwright proxy failures fall into a short list and are separable by symptom alone, without reading a log. Place yours below before changing configuration, because the expensive mistake is tuning a project-wide setting in response to a fault that lives on one device, or rewriting a proxy object that was correct all along.
| Symptom | Cause | Fix |
|---|---|---|
| 407 on every request | Wrong credential pair, or the other protocol's port | 407 proxy authentication |
| Context proxy appears ignored | No proxy at launch, which Chromium needs on some platforms | Launch with a placeholder server |
| Explicit socks5 authentication error | Chromium cannot authenticate SOCKS5 | Use the HTTP proxy port |
| Site serves the wrong region | DNS resolved locally over SOCKS5 | HTTP port — SOCKS5 vs HTTP |
| Hostnames fail instantly, literal IPs work | Stale resolver state on the device | SOCKS5 hostname failures |
| Navigation timeouts once workers go up | Too many parallel streams through one radio | workers: 1; abort images and fonts |
| Suite stalls ~3 minutes, then recovers unprompted | Multiplexer wedged under fan-out | Reduce concurrency; recovery is automatic |
| Steady 2-3 MB/s and no more | Normal — the cellular uplink ceiling | Nothing; slow speeds |
| Everything fails briefly after rotating | Radio re-attaching, ~7 seconds | Short backoff, then retry |
| Exit IP unchanged after a rotation | Wedged radio — the toggle was never acted on | Rotation diagnosis |
| Repeated 429s despite retries | Retrying inside the window the server named | Parse Retry-After — 429 handling |
| No connection, no status code | Wrong port or protocol | Proxy not working |
When this becomes an infrastructure problem
Everything above is code you own. It stops being a Playwright question when the configuration is right — HTTP port, credentials in the proxy object, one worker per device, Retry-After honoured, and results still degrade, because the remaining variable is the device and Playwright has no window into it.
The difference between a shared pool and a dedicated device is whether that variable is measurable. On a shared pool a stall might be your worker count or someone else's scraper on the same handset, and nothing distinguishes them. On a dedicated device your traffic is the only traffic on that radio, so worker count maps to behaviour in a way you can reason about, and rotation is an endpoint you call rather than a ticket you open. That is the shape QuantumProxy sells — one real 4G/5G handset per customer, both protocols exposed, rotation as an API. Mechanics in how mobile proxies work.
The honest limits. A mobile exit does not make an automated browser undetectable — address reputation is one signal among fingerprint, pacing and session continuity, and a headless browser supplies plenty of the others. It does not raise the throughput ceiling, and it does not help with a quota keyed to an account rather than an address, which is most authenticated APIs.
The check to run first
Two commands separate a Playwright problem from a proxy problem. The first compares the browser against a client with no contexts, no launch options and no worker pool; the second removes concurrency as a variable. Run both before editing configuration — together they take about a minute and eliminate most of the search space.
Runcurl -v --proxy http://host:port --proxy-user 'user:pass' https://api.ipify.orgfrom the same machine and read the exit address. If curl succeeds and Playwright does not, the proxy is healthy and the fault is in yourproxyobject — usually at the wrong level, or a context override that is not taking effect.
Then set workers: 1, abort images and fonts, and re-run. If the failures disappear you had a concurrency problem, and the answer is more devices rather than a different setting.
If both pass and the target still challenges you, the transport was never the issue — that is a detection question, and the general diagnosis order beats more configuration. The same constraints in other stacks: Puppeteer and Scrapy.
Frequently asked questions
How do I set a proxy in Playwright?
Pass a proxy object when launching the browser or creating a context: { server: 'http://host:port', username: 'user', password: 'pass' }. Unlike Chromium's raw command line, Playwright handles the authentication for you, so there is no separate authenticate call. The same object shape works at browser level, context level, and on an API request context.
Can I use a different proxy per context in Playwright?
Yes. browser.newContext({ proxy: { ... } }) overrides whatever the browser was launched with, so several contexts in one process can exit through different addresses while keeping separate cookies and storage. On some platforms Chromium requires a proxy at launch for per-context proxies to work, so the documented pattern is to launch with a placeholder server and override it in every context.
Does Playwright support SOCKS5 proxies with authentication?
SOCKS5 without credentials works. SOCKS5 with a username and password does not on Chromium, because the browser has no mechanism for SOCKS5 authentication; Playwright reports this as an explicit error rather than failing silently. If your provider requires credentials, use the HTTP proxy port instead.
Does Playwright leak DNS through a proxy?
It depends on the protocol. Through an HTTP proxy the CONNECT request carries the hostname, so resolution happens on the proxy side and nothing leaks. Through SOCKS5, Chromium resolves hostnames locally by default, so your own resolver sees every domain and the address it returns is selected for your real location rather than the proxy's.
How many workers should I use with a mobile proxy?
Start at one per device and raise it only if you have measured headroom. A mobile proxy exits through a single handset with an uplink around 2-3 MB/s, so parallel workers split a fixed resource rather than adding capacity, and very heavy parallel fan-out can wedge the device's connection multiplexer for a few minutes. More devices scale; more workers per device do not.
Why do my Playwright actions fail for a few seconds after rotating the IP?
Because rotation is physical. The handset detaches from the tower, tears down the bearer that carried the old address, and re-attaches to get a new one, which takes about seven seconds end to end. Anything in flight during that window fails. Retry with a short backoff rather than immediately, and treat the first failure after a rotation call as expected.
Related reading
- Puppeteer mobile proxy setup, including authenticated proxies
- Scrapy + mobile proxies: middleware, rotation and retry handling
- SOCKS5 vs HTTP proxies: which should you use with mobile?
- SOCKS5 works with an IP but fails with a hostname: how to diagnose it
- HTTP 429 Too Many Requests: Retry-After, backoff and rate limit types
- Dolphin Anty mobile proxy configuration guide
- AdsPower mobile proxy setup: a working 2026 configuration
Dedicated mobile proxies, one dashboard
Real 4G/5G devices on US carrier SIMs. Sticky IP per customer, rotation on demand.
See plans