A Puppeteer proxy is set at launch with Chrome's --proxy-server argument, which takes a host and port but no credentials, and authenticated per page with page.authenticate({ username, password }). To run several proxies from one browser, create a browser context per proxy with browser.createBrowserContext({ proxyServer }) and authenticate each page in it. If you can allowlist your server's IP, you can skip the authentication step altogether.
This guide walks through both patterns, then covers asset blocking, SOCKS5, timeouts, retries and exit-IP checks. The examples use a current Puppeteer release as an ES module and read the connection string from PROXY_URL, shaped like http://USERNAME:[email protected]:PORT. Your dashboard generates the exact string for the country and session mode you choose.
One proxy for the whole browser: --proxy-server and page.authenticate
import puppeteer from 'puppeteer';
const proxy = new URL(process.env.PROXY_URL);
const browser = await puppeteer.launch({
args: [`--proxy-server=${proxy.protocol}//${proxy.host}`],
});
try {
const page = await browser.newPage();
await page.authenticate({
username: decodeURIComponent(proxy.username),
password: decodeURIComponent(proxy.password),
});
await page.goto('https://api.ipify.org?format=json', { timeout: 30_000 });
console.log(await page.evaluate(() => document.body.innerText));
} finally {
await browser.close();
}
The code splits the connection string into two halves. proxy.protocol and proxy.host build the --proxy-server value, which is only scheme, host and port. Chrome does not accept credentials in this switch: in our tests, a --proxy-server value containing a username and password failed every navigation with net::ERR_NO_SUPPORTED_PROXIES. The username and password go to page.authenticate, URL-decoded so that a password containing @ or : reaches the gateway intact.
What happens on the wire is worth knowing. Chrome sends each new CONNECT without credentials, receives 407 Proxy Authentication Required, and Puppeteer answers the challenge with the stored credentials. In our tests, the first navigation paid for at least one rejected CONNECT before the tunnel opened. It is harmless, but it shows up in latency numbers, and it is one reason IP allowlisting is attractive for browser fleets: the gateway accepts the connection on the first attempt, and no page has to hold credentials. IP allowlist vs username and password covers the operational trade-offs.
Two rules follow from how Chrome handles those credentials:
- Call
page.authenticatebefore the first navigation on every page you create. - Know what is shared and what is not. In our tests, once one page had authenticated, Chrome cached the proxy credentials for its browser context: a popup or a second page in the same context connected without calling
page.authenticate, while a page in a new context failed withnet::ERR_INVALID_AUTH_CREDENTIALS. That is browser caching, not a documented Puppeteer guarantee, so authenticate every page you create, including popups (listen for the page'spopupevent), and treat each new context as unauthenticated.
Several proxies in one browser with createBrowserContext
Launching a browser per Puppeteer proxy works, but a Chrome process costs hundreds of megabytes. In current Puppeteer, browser.createBrowserContext() accepts a proxyServer option, so each context, with its own cookies and storage, can use its own proxy inside one browser process.
import puppeteer from 'puppeteer';
const sessions = process.env.PROXY_URLS.split(/\s+/).filter(Boolean).map((value) => new URL(value));
const browser = await puppeteer.launch();
try {
for (const proxy of sessions) {
const context = await browser.createBrowserContext({
proxyServer: `${proxy.protocol}//${proxy.host}`,
});
const page = await context.newPage();
await page.authenticate({
username: decodeURIComponent(proxy.username),
password: decodeURIComponent(proxy.password),
});
await page.goto('https://api.ipify.org?format=json', { timeout: 30_000 });
console.log(await page.evaluate(() => document.body.innerText));
await context.close();
}
} finally {
await browser.close();
}
PROXY_URLS holds a whitespace-separated list of connection strings: several sticky sessions generated in the dashboard, or one per dedicated ISP or datacenter address. createBrowserContext also accepts proxyBypassList, an array of hosts that should connect directly, which is the place for your own services and telemetry endpoints. The full option list is in the Puppeteer API reference.
Rotating or sticky sessions for a browser
Choose sticky sessions for browser work. Loading one page opens many connections, and on a rotating endpoint those connections can leave from different addresses mid-page. Many sites treat a session whose IP changes between the HTML document and its API calls as suspicious. The dependable pattern is one context per identity, one sticky session per context, and a context lifetime shorter than the session's window: on ProxyForge, up to 60 minutes for residential and up to 30 for mobile, while ISP and datacenter addresses are static. Rotating vs sticky proxies explains how to size the window for a target.
Blocking images, fonts and media to save bandwidth
Residential and mobile traffic is billed per GB, and images, fonts and video are usually most of a page's weight. Request interception lets you drop them before they are downloaded:
const BLOCKED_TYPES = new Set(['image', 'font', 'media']);
await page.setRequestInterception(true);
page.on('request', (request) => {
if (BLOCKED_TYPES.has(request.resourceType())) {
request.abort();
} else {
request.continue();
}
});
Add this after page.authenticate and before page.goto. page.authenticate already turns on interception internally, and the two work together: the authentication challenge is answered separately from your request handler.
Before you enable blocking everywhere, measure it. Interception disables the page cache, so scripts and stylesheets are fetched again on each navigation, which on script-heavy sites can offset what the blocked images saved. Blocking can also change what the page does: lazy-loaded content may never trigger, and some bot-detection scripts notice missing resources. Blocking third-party analytics and ad hosts by URL is often a cheaper win with fewer side effects. If several handlers in your code need to intercept requests, Puppeteer's cooperative interception mode lets them coexist.
SOCKS5, timeouts and retries
SOCKS5
Chrome accepts --proxy-server=socks5://gateway.proxyforge.io:PORT and resolves hostnames at the proxy for SOCKS5, so DNS lookups do not leak to your local resolver. It has no way to send SOCKS5 credentials, and page.authenticate answers HTTP challenges only, so SOCKS5 in Puppeteer means IP allowlisting. With credentials, use the HTTP endpoint; HTTPS pages travel inside a CONNECT tunnel either way, so you give up nothing.
Timeouts
Puppeteer's default navigation timeout is 30 seconds. Set it explicitly, per call or with page.setDefaultNavigationTimeout(), so the budget is visible in code. Wait for the condition you actually need (domcontentloaded, or a selector) rather than full network idle; waiting for every tracker to finish downloading costs both time and bandwidth through the proxy.
Reading the errors and retrying
Proxy problems show up as navigation errors, not HTTP statuses:
| Error | Usual meaning | Retry? |
|---|---|---|
net::ERR_PROXY_CONNECTION_FAILED |
The gateway host or port is unreachable | Once, then check the connection string and network egress |
net::ERR_INVALID_AUTH_CREDENTIALS |
The 407 challenge went unanswered or was rejected: no page.authenticate on this page, wrong credentials, or an IP that is not allowlisted |
No; fix authentication first |
net::ERR_NO_SUPPORTED_PROXIES |
The --proxy-server value is malformed, for example it contains credentials |
No; fix the launch arguments |
net::ERR_TUNNEL_CONNECTION_FAILED |
The proxy accepted you but could not open a tunnel to the target | Yes, with backoff and a new session |
| Navigation timeout | Slow exit or slow target | Yes, in a fresh context with a new session |
| HTTP 403 or 429 from the target | Block or rate limit at the target | In a fresh context, with backoff |
Retry in a new context rather than the same page: the same page keeps the same exit and the same cookies that were just refused. Proxy 403 and 429 errors covers how to separate target-side blocks from proxy failures.
Verifying the exit IP of each session
Both examples above navigate to an IP echo service first. Keep that step in production: open the check page in the same context that will do the work, record the address, and fail the job if it matches your server's own address. Checking with curl from the shell proves only that the shell's proxy works, not that the browser is using it.
For concurrency, remember that each context and each page consume memory in the browser process. Bound the number of concurrent pages with a worker pool sized from measurements on your real targets, and restart the browser periodically on long runs. The proxy is rarely the limit; the machine running Chrome usually is.
Which proxy line fits a Puppeteer workload
A browser downloads everything a page references, so a Puppeteer proxy carries far more bytes per page than an HTTP client does. That pushes heavy, repetitive browser work toward per-address lines.
| Workload | Line | Notes |
|---|---|---|
| Consumer sites that score IP reputation | Residential | Sticky up to 60 minutes per context; block heavy assets to control per-GB cost |
| Long-lived accounts that should keep one address | ISP | Static, dedicated, billed per address, so page weight does not change the bill |
| Rendering and screenshots of tolerant or first-party sites | Datacenter | Dedicated addresses from our own ASN |
| Mobile web views and carrier-specific content | Mobile | Sticky up to 30 minutes; pair with mobile emulation |
If you have not yet decided between the Chrome-driving tools, the Playwright proxy guide covers the same ground for Playwright, which configures credentials per context without a separate authentication call.
Running Puppeteer on ProxyForge
Every ProxyForge line accepts HTTP, HTTPS and SOCKS5, with username and password or IP allowlist authentication, so either pattern above works across lines. For browser fleets, allowlisting the egress IPs of your render nodes removes page.authenticate from the code path entirely.
Pricing is pay-as-you-go from a prepaid wallet, from 1 GB or 1 IP, with published rates on the pricing page. If you are moving an existing Puppeteer fleet from another provider, the migration process mirrors your current endpoint and session format so you can compare both on your own dashboards before shifting traffic.