How to Use Proxies with Puppeteer
Use proxies with Puppeteer: set --proxy-server launch args, per-context proxies, page.authenticate for auth, rotation, stealth tips, and error fixes.

Setting up proxies with Puppeteer has one trap that breaks most first attempts: Chrome silently ignores any username and password you put in the --proxy-server flag. Pass --proxy-server=user:pass@host:port and the credentials vanish, so the browser hits a 407 loop and nothing loads. This guide covers every working method: launch args, page.authenticate() for credentials, per-context proxies that run several IPs in one browser process, rotation, SOCKS5, a full error reference, stealth tuning, and when to skip the whole setup and call the SparkProxy Scraping API instead.
Why Route Puppeteer Through a Proxy?
A proxy in Puppeteer routes the entire browser's network stack through another IP: page loads, XHR and fetch calls, images, fonts, WebSocket upgrades, everything Chromium requests. This differs from an HTTP-library proxy in Node's axios or undici, which only reroutes the calls your own code makes. When a page's JavaScript fires background API requests, those go through the browser proxy too, which is exactly what you want for scraping dynamic sites.
| Use case | Why the proxy matters |
|---|---|
| Scraping geo-restricted content | Present a specific country's exit IP so the site serves local data |
| Price and inventory monitoring | Spread requests across IPs to avoid single-IP rate limiting |
| Ad verification | Render ads from the target region's IP to see what real users see |
| Multi-account automation | Bind a stable IP per account to reduce association |
| SERP and marketplace scraping | Rotate IPs to keep collecting past soft blocks |
Puppeteer 22+ ships with a bundled Chrome for Testing build, so the examples below assume puppeteer (not puppeteer-core) on Node 18 or newer.
Set a Proxy with Launch Args
The base method passes --proxy-server in the args array at launch. This applies to every page the browser opens. It works cleanly for proxies that use IP whitelisting, where your server's public IP is authorized in the SparkProxy dashboard so no credentials are needed.
import puppeteer from 'puppeteer';
const browser = await puppeteer.launch({
headless: true,
args: [
'--proxy-server=dc.sparkproxy.io:10000',
'--no-sandbox',
'--disable-dev-shm-usage',
],
});
const page = await browser.newPage();
await page.goto('https://httpbin.org/ip', { waitUntil: 'networkidle2' });
console.log(await page.evaluate(() => document.body.innerText));
await browser.close();
The value after --proxy-server= is host:port. Omit the scheme for a standard HTTP proxy and Chrome uses HTTP CONNECT tunneling by default. To skip the proxy for specific hosts, add a second arg like --proxy-bypass-list=localhost;127.0.0.1;*.internal.sparkproxy.io. Note the separator: Chrome's --proxy-bypass-list uses semicolons, not commas, which is a common copy-paste bug.
Scraping at scale? Skip the blocks.
Fast, unblockable datacentre proxies with unlimited bandwidth.
Proxy Authentication with page.authenticate()
Most datacenter and residential plans use username and password auth. Here is the rule that trips people up:
Chrome does not accept credentials in the --proxy-server flag.
// THIS DOES NOT WORK, credentials are silently dropped
args: ['--proxy-server=user:pass@dc.sparkproxy.io:10000'];
The credentials never reach the proxy, so the target returns a 407 challenge that Puppeteer cannot answer, and navigation hangs or throws net::ERR_INVALID_AUTH_CREDENTIALS. The fix is page.authenticate(), which registers credentials over the DevTools protocol and answers the proxy's auth challenge for you.
import puppeteer from 'puppeteer';
const browser = await puppeteer.launch({
headless: true,
args: ['--proxy-server=dc.sparkproxy.io:10000', '--no-sandbox'],
});
const page = await browser.newPage();
// Must run BEFORE the first navigation on this page
await page.authenticate({
username: 'YOUR_PROXY_USER',
password: 'YOUR_PROXY_PASS',
});
await page.goto('https://httpbin.org/ip', { waitUntil: 'domcontentloaded' });
console.log(await page.evaluate(() => document.body.innerText));
await browser.close();
Two details decide whether this works. First, call page.authenticate() before page.goto(), never after. Second, credentials are scoped to the page, so every new page you open needs its own authenticate() call. If you open ten tabs, you authenticate ten times.
For a deeper look at how the 407 handshake works under the hood, see the SparkProxy explainer on how proxy authentication works.
Per-Context Proxies in One Browser
Here is the technique most Puppeteer proxy guides still miss. Since Puppeteer 22.6, browser.createBrowserContext() accepts a proxyServer option, so you can run several different proxies inside a single browser process without relaunching Chrome. Each browser context is an isolated cookie and cache jar, and now it can carry its own exit IP.
import puppeteer from 'puppeteer';
const browser = await puppeteer.launch({ headless: true, args: ['--no-sandbox'] });
// Context A on one proxy
const ctxA = await browser.createBrowserContext({
proxyServer: 'http://dc.sparkproxy.io:10000',
proxyBypassList: ['localhost', '127.0.0.1'],
});
// Context B on a different proxy, same browser process
const ctxB = await browser.createBrowserContext({
proxyServer: 'http://dc.sparkproxy.io:10001',
});
const pageA = await ctxA.newPage();
await pageA.authenticate({ username: 'USER_A', password: 'PASS_A' });
await pageA.goto('https://httpbin.org/ip');
const pageB = await ctxB.newPage();
await pageB.authenticate({ username: 'USER_B', password: 'PASS_B' });
await pageB.goto('https://httpbin.org/ip');
console.log('A:', await pageA.evaluate(() => document.body.innerText));
console.log('B:', await pageB.evaluate(() => document.body.innerText));
await browser.close();
createBrowserContext() replaced the deprecated createIncognitoBrowserContext() in Puppeteer 22, so update older snippets that still call the incognito method. The proxyServer takes a full URL string, and proxyBypassList takes an array of host patterns. Credentials still go through page.authenticate() on each page inside the context, because auth is a page-level concern even when the proxy is set at the context level.
This matters for throughput. Launching a fresh Chrome per proxy costs 200 to 400 MB of RAM and a cold start each time. Twenty proxies means twenty browsers. With per-context proxies, one browser process holds twenty isolated contexts, each on its own IP, and you skip the relaunch cost entirely.
Rotating Proxies Across Pages and Contexts
There are two rotation models, and the right one depends on your Puppeteer version and workload.
Rotate by context (recommended, Puppeteer 22.6+)
Keep one long-lived browser and hand each job a fresh context bound to the next proxy in your pool. Round-robin keeps the load even.
import puppeteer from 'puppeteer';
const PROXIES = [
{ server: 'http://dc.sparkproxy.io:10000', user: 'U1', pass: 'P1' },
{ server: 'http://dc.sparkproxy.io:10001', user: 'U2', pass: 'P2' },
{ server: 'http://dc.sparkproxy.io:10002', user: 'U3', pass: 'P3' },
];
const browser = await puppeteer.launch({ headless: true, args: ['--no-sandbox'] });
let i = 0;
async function scrape(url) {
const proxy = PROXIES[i++ % PROXIES.length]; // round-robin
const ctx = await browser.createBrowserContext({ proxyServer: proxy.server });
const page = await ctx.newPage();
try {
await page.authenticate({ username: proxy.user, password: proxy.pass });
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
return await page.content();
} finally {
await ctx.close(); // frees the context, not the whole browser
}
}
const targets = ['https://httpbin.org/ip', 'https://httpbin.org/headers'];
for (const t of targets) console.log((await scrape(t)).slice(0, 120));
await browser.close();
Call ctx.close() when a job finishes so contexts do not pile up. Because the browser stays open, each job pays only the context startup cost, which is milliseconds.
Rotate by browser process (any version)
If you are on an older Puppeteer, or you want full process isolation per IP, launch a new browser per proxy. It is heavier but simple and version-agnostic.
async function scrapeWithNewBrowser(url, proxy) {
const browser = await puppeteer.launch({
headless: true,
args: [`--proxy-server=${proxy.server}`, '--no-sandbox'],
});
const page = await browser.newPage();
try {
await page.authenticate({ username: proxy.user, password: proxy.pass });
await page.goto(url, { waitUntil: 'domcontentloaded' });
return await page.content();
} finally {
await browser.close();
}
}
The rotation logic (round-robin, weighted, sticky per account, retry on failure) is the same theory whatever the language. For the algorithm design side, including per-IP request budgeting, see how to rotate proxies in Python, which maps cleanly onto Node.
SOCKS5 and Other Proxy Schemes
Chrome supports HTTP, HTTPS, and SOCKS5 proxies through the same --proxy-server flag. Prefix the value with the scheme:
// SOCKS5 via launch args (IP-whitelisted, no in-flag auth)
const browser = await puppeteer.launch({
args: ['--proxy-server=socks5://dc.sparkproxy.io:1080', '--no-sandbox'],
});
The same socks5:// URL works as the proxyServer value in createBrowserContext() too. One caveat specific to SOCKS: Chrome does not support username and password authentication for SOCKS5 proxies through page.authenticate(). If your SOCKS5 endpoint needs credentials, use IP whitelisting instead, or route through an HTTP proxy where page.authenticate() works.
By default, Chrome resolves DNS locally before connecting through SOCKS5. If you need the proxy exit to resolve DNS (to prevent DNS leaks and match the exit geo), enforce it with a --host-resolver-rules arg rather than relying on the browser's local resolver. Most datacenter scraping runs fine on plain HTTP proxies, so reach for SOCKS5 only when a target or protocol specifically needs it.
Handling Proxy Errors
Proxy failures in Puppeteer surface as net::ERR_* strings thrown from page.goto(). Wrap navigation, inspect the message, and rotate or retry.
async function safeGoto(page, url, proxy) {
try {
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
return true;
} catch (err) {
const msg = err.message;
if (msg.includes('ERR_TUNNEL_CONNECTION_FAILED') ||
msg.includes('ERR_PROXY_CONNECTION_FAILED')) {
console.warn(`Proxy ${proxy.server} unreachable, rotating.`);
return false; // caller picks the next proxy
}
if (msg.includes('ERR_INVALID_AUTH_CREDENTIALS')) {
console.error('Bad proxy credentials or authenticate() ran after goto.');
throw err;
}
throw err; // real page error, surface it
}
}
The common ones and their fixes:
| Error string | Cause | Fix |
|---|---|---|
| `net::ERR_INVALID_AUTH_CREDENTIALS` | Credentials in `--proxy-server`, or `authenticate()` called after `goto()` | Move `page.authenticate()` before the first `goto()`; never put user:pass in the flag |
| `net::ERR_TUNNEL_CONNECTION_FAILED` | Proxy host unreachable or port blocked | Test `curl -x host:port https://httpbin.org/ip`; confirm the port is open |
| `net::ERR_PROXY_CONNECTION_FAILED` | Proxy down or wrong host | Rotate to a healthy proxy; verify the endpoint |
| `net::ERR_NO_SUPPORTED_PROXIES` | Wrong scheme (for example `https://` for an HTTP proxy) | Drop the scheme or use `http://` / `socks5://` correctly |
| `net::ERR_EMPTY_RESPONSE` | Proxy dropped the connection mid-response | Retry; if it repeats, the IP is likely blocked, rotate it |
| Navigation hangs, no error | 407 loop from missing auth | Add `page.authenticate()` before navigation |
If pages load but the target keeps serving CAPTCHAs or blocks, the problem is detection, not connectivity. The playbook for that (fingerprint hygiene, request pacing, IP quality) is covered in how to avoid getting your proxy blocked.
Stealth Tips for Proxied Puppeteer
A clean IP does not help if the browser broadcasts that it is automated. Pair your proxy with these adjustments.
Use puppeteer-extra with the stealth plugin to patch the most common automation tells (navigator.webdriver, headless Chrome markers, WebGL vendor strings):
import puppeteer from 'puppeteer-extra';
import StealthPlugin from 'puppeteer-extra-plugin-stealth';
puppeteer.use(StealthPlugin());
const browser = await puppeteer.launch({
headless: true, // default "new" headless in Puppeteer 22+
args: ['--proxy-server=dc.sparkproxy.io:10000', '--no-sandbox'],
});
Stick with the default new headless mode (headless: true in Puppeteer 22+). The legacy chrome-headless-shell (set via headless: 'shell') exposes more detectable signals and should only be used when you need its lower footprint.
Match the browser's declared identity to the proxy's exit country. A German exit IP with an en-US locale and a New York timezone is an obvious mismatch:
const page = await browser.newPage();
await page.authenticate({ username: 'USER', password: 'PASS' });
// Align locale, timezone, and headers with the proxy geo (example: Germany)
await page.setExtraHTTPHeaders({ 'Accept-Language': 'de-DE,de;q=0.9' });
await page.emulateTimezone('Europe/Berlin');
await page.setUserAgent(
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ' +
'(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36'
);
await page.goto('https://httpbin.org/headers');
Two more habits pay off. Add human-like pacing with small randomized delays between actions instead of firing navigations back to back. And prefer residential exits for the hardest targets, since datacenter subnets are easier to flag. The tradeoffs between datacenter, residential, and browser-automation proxies are laid out in the SparkProxy guide on integrating proxies with Selenium, which shares the same underlying Chrome behavior as Puppeteer.
Offloading to the SparkProxy Scraping API
Running Puppeteer plus a proxy pool plus stealth patches plus retry logic is a lot of moving parts to maintain. When you would rather send a URL and get back rendered HTML, the SparkProxy Scraping API handles the headless browser, proxy rotation, and anti-bot layers on the server side. It is documented at the SparkProxy Scraping API reference.
The endpoint is https://scrape.sparkproxy.io/api/v1, authenticated with an X-API-Key header. A minimal call from Node using the built-in fetch:
const params = new URLSearchParams({
url: 'https://www.sparkproxy.io',
render_js: 'true', // headless Chromium rendering
premium_proxy: 'true', // residential exit
country_code: 'US', // ISO 3166-1 alpha-2
format: 'html',
});
const res = await fetch(`https://scrape.sparkproxy.io/api/v1?${params}`, {
headers: { 'X-API-Key': 'sk-YOUR_API_KEY' },
});
console.log('Credits used:', res.headers.get('X-Credits-Used'));
const html = await res.text();
console.log(html.slice(0, 200));
If you still need browser interactions (click, scroll, fill, wait for a selector), you can describe them with js_scenario and let the API drive the page, so you keep Puppeteer-style control without managing the browser or the proxies yourself:
const body = {
url: 'https://www.sparkproxy.io/pricing',
render_js: true,
premium_proxy: true,
wait_for: '#pricing-table', // wait for a selector (max 30s)
js_scenario: {
steps: [
{ click: '#load-more' },
{ wait: 2 },
],
},
format: 'md', // return clean Markdown
};
const res = await fetch('https://scrape.sparkproxy.io/api/v1', {
method: 'POST',
headers: { 'X-API-Key': 'sk-YOUR_API_KEY', 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const data = await res.text();
Use self-managed Puppeteer when you need deep, stateful control of a real browser you own. Use the Scraping API when the goal is reliable extraction at scale and you would rather not babysit browsers and IPs.
Frequently asked questions
FAQ
No. Chrome silently drops credentials passed in --proxy-server, which causes a 407 loop or ERR_INVALID_AUTH_CREDENTIALS. Call page.authenticate({ username, password }) before the first page.goto() instead, which answers the proxy's authentication challenge over the DevTools protocol.
Not per individual page, but per browser context, which is close. Create a context with browser.createBrowserContext({ proxyServer }) (Puppeteer 22.6+) and open pages inside it. Every page in that context shares the context's proxy, and different contexts can use different proxies in the same browser process.
Use per-context proxies. Keep one long-lived browser and create a fresh context bound to the next proxy in your pool for each job, then call context.close() when the job finishes. This avoids the 200 to 400 MB and cold-start cost of launching a new Chrome per proxy.
The two usual causes are calling it after page.goto() instead of before, or calling it on a different page than the one that navigates. Credentials are scoped to a single page, so run page.authenticate() on each new page before its first navigation.
Use the default new headless mode (headless: true in Puppeteer 22+). The legacy chrome-headless-shell (headless: 'shell') exposes more automation signals and is easier for anti-bot systems to flag. Combine the new headless mode with puppeteer-extra-plugin-stealth for the best results.
Datacenter proxies work well for many targets and are faster and cheaper, so start there. Switch to residential exits (the premium_proxy option on the SparkProxy Scraping API) for sites with aggressive anti-bot detection, where datacenter subnets get flagged quickly.
Get 50% off your first purchase
Premium datacentre proxies with unlimited bandwidth. Use the code at checkout.
Offer ends soon — claim it before it's gone
Related articles

Bypass Cloudflare Web Scraping: An Ethical 2026 Guide
Bypass Cloudflare web scraping blocks the ethical way: TLS/JA3 fingerprints, JS challenges, Turnstile, plus real-browser and SparkProxy Scraping API fixes.

How to Scrape Google Search Results Without Blocks
Scrape Google search results at scale without getting blocked. Learn request pacing, geo-targeting, SERP parsing, and a working SparkProxy API example.

How to Rotate Proxies in Node.js
Rotate proxies in Node.js with round-robin, random, weighted, and sticky strategies, plus working axios, got, and node-fetch code and a reusable pool class.
