If you run Puppeteer at scale without proxies, you risk IP bans, missing data, and broken tests. This guide shows how to set up proxies in Puppeteer, including configuration, rotation, and troubleshooting.
3 Types of Proxy Setup in Puppeteer
One of these is native and the other two are ways around the fact that Chromium sets its proxy once, for the whole browser process.
Static proxy via —proxy-server
The simplest way to use a proxy in Puppeteer is with the --proxy-server option. This sets the proxy for the entire browser, and all tabs or contexts inherit it. Unlike other methods, this approach is native and doesn’t require any additional packages.
To use it, pass the argument when creating the browser instance:
import puppeteer from 'puppeteer';
const browser = await puppeteer.launch({
args: ['--proxy-server=http://HOST:PORT']
});
const page = await browser.newPage();
await page.goto('https://httpbin.org/ip', { waitUntil: 'domcontentloaded' });
console.log(await page.evaluate(()=>document.body.innerText));
await browser.close();This one has never needed maintenance. It still works unchanged on Puppeteer 25, and httpbin.org/ip comes back with the proxy’s address rather than yours.
Page-level proxy with request interception
Puppeteer has no native per-page proxy, and the package that used to fill the gap has stopped working.
puppeteer-page-proxy was the standard answer. Its last release is 1.3.0 from November 2022, and on Puppeteer 25 every intercepted request throws before it reaches the network:
TypeError: useProxyPer[target.constructor.name] is not a function
at useProxy (node_modules/puppeteer-page-proxy/src/core/proxy.js:92)The library branches on the class name of the object it is handed. Puppeteer’s intercepted request is now a CdpHTTPRequest, the lookup finds nothing, and the navigation ends as net::ERR_FAILED. Nothing in the message points at the proxy, which is what makes it expensive to debug. You won’t find it without logging the handler.
Routing every page through one local proxy and picking the upstream per request does not rescue it either. A front proxy sees the CONNECT line, and a CONNECT carries none of the headers the page set, so there’s nothing in it to route on. In a run with two pages tagged with different headers, all eight requests went to the same upstream while both pages’ headers arrived at the target intact.
What’s left is one browser per proxy, below.
Browser context-level proxy
The --proxy-server option applies to the entire browser, so a per-browserContext proxy is not something Puppeteer offers.
To handle this, you can:
- Launch separate browser instances, each with its own
--proxy-server. - Use request interception (see the previous section).
Running multiple browser processes increases CPU and memory usage. There’s no native alternative at the moment.
Example with two browser instances:
const browser = await puppeteer.launch({ args: ['--proxy-server=http://p1:port'] });
const browser2 = await puppeteer.launch({ args: ['--proxy-server=http://p2:port'] });Two browsers with one proxy each navigate in parallel without interfering, which is the whole reason this clumsy approach is still the recommended one.
Handling Proxy Authentication
First, note that the following method does not work in Puppeteer:
--proxy-server=http://user:pass@host:portPuppeteer controls Chromium, and Chromium does not support credentials in the --proxy-server URL. This is documented in the Chromium proxy docs. So, Puppeteer cannot pass username/password through the URL either.
Using page.authenticate()
Puppeteer handles proxy auth through page.authenticate(). This is the official way to pass proxy credentials. It works with HTTP/HTTPS and must run before navigation:
// Launch browser with global proxy
const browser = await puppeteer.launch({ args: ['--proxy-server=http://host:port'] });
const page = await browser.newPage();
// Authenticate proxy
await page.authenticate({ username: 'user', password: 'pass' });
await page.goto('https://example.com');This works on current Puppeteer. In a run against a proxy that demands credentials, the proxy confirmed it received them and the page loaded, where the same launch without authenticate() returns a 407.
Third-party libraries and workarounds
There are workarounds for proxies that require authentication:
- Use third-party libraries that attach credentials at the request or page level.
- Localize (anonymize) an upstream proxy, then pass the local proxy URL to
--proxy-server.
The second one is the one to reach for, and proxy-chain does it in two lines. It starts a local proxy that holds your credentials and forwards upstream, so Chromium only ever sees an unauthenticated local address:
import puppeteer from 'puppeteer';
import { anonymizeProxy, closeAnonymizedProxy } from 'proxy-chain';
const local = await anonymizeProxy('http://USER:PASS@HOST:PORT');
const browser = await puppeteer.launch({ args: [`--proxy-server=${local}`] });
const page = await browser.newPage();
await page.goto('https://httpbin.org/ip');
console.log(await page.evaluate(() => document.body.innerText));
await browser.close();
await closeAnonymizedProxy(local, true);That run returns the proxy’s IP on Puppeteer 25. proxy-chain is at 3.0.1 and still shipping, which is more than the per-page libraries manage. puppeteer-page-proxy stopped at release 1.3.0 in November 2022, and @extra/proxy-router at release 3.1.6 in March 2023.
Advanced Proxy Techniques
Use extra measures like setting realistic headers and rotating proxies regularly to reduce the risk of bans and make proxies last longer.
Building proxy rotation scripts
Proxy rotation is simple. Use a pool of proxies and switch between them either continuously or when bans/errors occur.
const pool = ['http://host1:port','http://host2:port'];
let i = 0;
const next = () => pool[(i++) % pool.length];
const proxy = next();You can also use a rotating endpoint from a proxy provider. This is easier to implement and usually comes with access to a much larger pool, with the main downside being increased costs.
Track banned proxies to improve reliability. Keep metrics and skip bad proxies:
const pool = new Set(['proxy1','proxy2','proxy3']);
const banned = new Set();
function next() {
const candidates = [...pool].filter(x=>!banned.has(x));
return candidates[Math.floor(Math.random()*candidates.length)];
}
function markBad(p){ banned.add(p); }Rotating on error rather than on a timer keeps a working exit in place until it stops working, which costs fewer requests than cycling on every call.
Dynamic user-agent and header rotation
Always use realistic User-Agent, Accept-Language, and other headers when switching IPs. Proxies alone are not enough. You can find the latest User Agents in our blog.
Example:
await page.setUserAgent('Mozilla/5.0 (Windows NT 10.0; Win64; x64) ...');
await page.setExtraHTTPHeaders({ 'Accept-Language': 'en-US,en;q=0.9' });A user agent that disagrees with the rest of the headers is worse than not rotating at all, so move the whole set together.
Reducing headless fingerprints
Headless Chrome exposes several properties that differ from a normal desktop Chrome, such as the navigator.webdriver flag, the headless user-agent, and missing plugin and language values. The puppeteer-extra-plugin-stealth plugin for puppeteer-extra normalizes those properties.
These modules work like standard Puppeteer but normalize the headless browser’s default properties. Use them when a site treats a plain Puppeteer session as automated.
Run this in your terminal:
npm i puppeteer-extra puppeteer-extra-plugin-stealthUsage example:
import puppeteer from 'puppeteer-extra';
import StealthPlugin from 'puppeteer-extra-plugin-stealth';
puppeteer.use(StealthPlugin());
const browser = await puppeteer.launch({ headless: true, args: ['--proxy-server=...'] });Their last releases are 3.3.6 and 2.11.2, both from March 2023, so treat the patches puppeteer-extra and its stealth plugin apply as a starting point rather than a current list.
Troubleshooting Proxy Issues
Almost every proxy failure in Puppeteer surfaces as a net:: error on page.goto(), and the error name is specific enough to tell you which layer broke.
Fixing authentication and connection errors
Most issues come from incorrect usage or authentication problems. Here are the common Puppeteer errors and fixes:
| Error | Meaning / Cause | Fix |
|---|---|---|
| net::ERR_PROXY_CONNECTION_FAILED | Wrong host / port / protocol | Double-check proxy string (http://host:port) |
| net::ERR_NO_SUPPORTED_PROXIES | Unsupported scheme | Use correct scheme: http://, https://, socks4://, socks5:// |
| net::ERR_TUNNEL_CONNECTION_FAILED | Proxy can’t reach the target site | Test proxy externally (curl -x proxy …), switch to another one |
| net::ERR_TIMED_OUT | Proxy is alive but too slow | Drop slow IPs, add timeout & retry logic |
| net::ERR_HTTP_RESPONSE_CODE_FAILURE 407 | Proxy auth required, but missing credentials | Call page.authenticate({ user, pass }) or use a proxy lib |
| net::ERR_FAILED with no other detail | An interception handler threw before the request went out | Wrap the request handler in try/catch and log the error, the navigation error will not name the cause |
| TypeError: useProxyPer[…] is not a function | puppeteer-page-proxy on current Puppeteer | The package branches on the old request class name and Puppeteer now passes a CdpHTTPRequest. Use one browser per proxy |
| net::ERR_EMPTY_RESPONSE | Proxy accepted the connection then dropped it | Usually a dead exit in the pool, retry on another and mark it down |
Chromium does not support embedding credentials in the proxy URL.
Resolving misconfigurations and inconsistent output
Don’t mix global --proxy-server with per-request interception. Use only one method per browser to avoid unpredictable IP behavior.
Mixing them splits traffic. Some requests go through host1, some through host2, and some bypass the proxy, which makes debugging unreliable.
// Wrong: Mixing global proxy and per-request proxying
await puppeteer.launch({ args: ['--proxy-server=http://host1:port'] });
page.on('request', req => useProxy(req, 'http://host2:port'));On current Puppeteer this particular pairing fails at the interception step before the split can even happen, but the rule holds for any per-request routing you write yourself.
Monitoring proxy health at scale
Some proxies will fail at scale. If you don’t check them, jobs hang. Test each proxy against httpbin, which returns the IP that made the request. Track success rate and latency, and remove bad nodes to keep scraping stable.
try {
await page.goto('https://httpbin.org/ip', { timeout: 2000 });
} catch {
console.log('Proxy dead, skip');
}A two second timeout is aggressive. A proxy that can’t answer a one line JSON endpoint in two seconds won’t carry a real page.
Alternative to rotating proxies in Puppeteer
Managing your own proxy pool works right up until it doesn’t. Credentials, rotation logic, retries and health checks all take time you wanted to spend on the data.
The other route is to let the proxy pool be somebody else’s problem. HasData’s web scraping API takes the target and the exit you want, and returns the page. There’s no rotation to write, no page.authenticate(), and no browser to keep alive.
It needs no SDK, just a POST:
// To get an API key, sign up at https://app.hasdata.com/sign-up
const res = await fetch('https://api.hasdata.com/scrape/web', {
method: 'POST',
headers: { 'x-api-key': 'YOUR-API-KEY', 'Content-Type': 'application/json' },
body: JSON.stringify({
url: 'https://httpbin.org/ip',
proxyType: 'datacenter',
proxyCountry: 'US',
}),
});
const data = await res.json();
console.log(data.content);

