Node.js is the default runtime for a huge amount of scraping and automation work: an event loop that shrugs off thousands of concurrent requests, a massive package ecosystem, and the same language front to back. Wiring a residential proxy into it is a few lines, but the details trip people up in a way specific to Node, because the most popular HTTP client, Axios, has a proxy option that does not do what you expect over HTTPS. Get the agent right and the rest is easy.
This is the Node.js entry in the same series as residential proxies with Python, with Playwright, and in Go: the code that works for Axios, Got, and native fetch, plus the traps that are unique to the Node ecosystem.
Everything below uses Shifter’s residential gateway: one endpoint, p.shifter.io:443, with all targeting encoded in the username. Swap the host and credentials for another provider; the shape is the same.
The gateway model in one paragraph
The proxy username carries your authentication and your targeting. You do not switch endpoints to change country or session, you change the username string:
customer-USERNAME-country-us-sid-abc123-ttl-600country-us targets the United States, sid pins a sticky session, ttl holds that IP for N seconds. Omit sid/ttl and every new connection rotates. The password stays constant. In Node, that whole string goes into the proxy URL you hand to a proxy agent.
Axios: use an agent, not the built-in proxy option
Here is the single most important thing in this post. Axios has a proxy option, and for HTTPS targets with authentication it is unreliable, it does not open a proper CONNECT tunnel and quietly fails or leaks your real IP. The fix the ecosystem settled on is to hand Axios an httpsAgent built from https-proxy-agent, and set proxy: false so Axios does not try to handle the proxy itself.
import axios from 'axios';import { HttpsProxyAgent } from 'https-proxy-agent';
const user = `${process.env.SHIFTER_USER}-country-us`;const pass = process.env.SHIFTER_PASS;const proxyUrl = `http://${user}:${pass}@p.shifter.io:443`;
const agent = new HttpsProxyAgent(proxyUrl);
const client = axios.create({ httpsAgent: agent, proxy: false, // critical: let the agent handle it, not Axios timeout: 30000,});
const res = await client.get('https://api.ipify.org');console.log(res.data); // a US residential IPTwo things to internalize. The user string includes the targeting flags (-country-us), because the geo lives there. And proxy: false is not optional, without it Axios’s own proxy logic collides with the agent and you get the exact broken behavior you were trying to avoid. This one line is the most common Node-proxy bug.
Got: the agent goes in the agent.https slot
Got takes the proxy agent through its agent option, keyed by protocol. Same https-proxy-agent under the hood, no proxy: false dance because Got has no built-in proxy handling to fight with.
import got from 'got';import { HttpsProxyAgent } from 'https-proxy-agent';
const proxyUrl = `http://${user}:${pass}@p.shifter.io:443`;
const res = await got('https://api.ipify.org', { agent: { https: new HttpsProxyAgent(proxyUrl) }, timeout: { request: 30000 },});console.log(res.body); // a US residential IPOne packaging note: Got has been pure ESM since v12, so import got from 'got' needs an ESM project ("type": "module" in package.json) or a dynamic import(). If you are stuck on CommonJS, either stay on Got v11 or switch to Axios/undici. This ESM-vs-CommonJS split is a Node-specific stumbling block, not a proxy issue, but it bites people setting this up for the first time.
Native fetch: undici’s ProxyAgent as the dispatcher
Node 18+ ships a global fetch backed by undici, and undici has its own proxy support that does not use https-proxy-agent at all. You pass a ProxyAgent as the request’s dispatcher:
import { ProxyAgent } from 'undici';
const dispatcher = new ProxyAgent(`http://${user}:${pass}@p.shifter.io:443`);
const res = await fetch('https://api.ipify.org', { dispatcher });console.log(await res.text()); // a US residential IPTo route every fetch in the process through the proxy, set it globally instead:
import { setGlobalDispatcher, ProxyAgent } from 'undici';setGlobalDispatcher(new ProxyAgent(proxyUrl));If you are on modern Node and want zero HTTP-client dependencies, this is the cleanest path.
Trap 1: reuse the agent, do not build one per request
Whichever client you pick, the proxy agent owns the connection pool. Constructing a fresh HttpsProxyAgent or ProxyAgent for every request throws away keep-alive and pays a full TCP + TLS handshake through the proxy every single time, the overhead the latency guide exists to remove. Build the agent once for a given identity and reuse it across requests. Create the Axios/Got client (or the undici dispatcher) at startup and hold it.
Trap 2: bound your concurrency, the event loop will not do it for you
Node’s event loop makes it trivial to fire a thousand requests at once, and nothing stops you. await Promise.all(urls.map(fetchOne)) on a large array will open every connection simultaneously, which exhausts sockets on your side and looks like an attack to the target. Cap in-flight requests with a small concurrency limiter (p-limit is the common choice) or a simple queue:
import pLimit from 'p-limit';
const limit = pLimit(8); // at most 8 requests in flightconst results = await Promise.all( urls.map(url => limit(() => client.get(url))));Cap concurrency per target host, not just globally, so one fragile site is not hammered while a permissive one is starved. More parallelism past a target’s tolerance buys blocks, not throughput (how to avoid getting blocked). Match the limit to what each host tolerates.
Trap 3: async errors need explicit handling
A proxy or connection failure surfaces as a rejected promise, and an unhandled rejection can crash the process or, worse, silently drop a task in a batch. Wrap each request so a transport failure retries with a fresh identity rather than taking down the run:
async function fetchWithRetry(client, url, attempts = 3) { for (let i = 0; i < attempts; i++) { try { return await client.get(url); } catch (err) { if (i === attempts - 1) throw err; // transient (ECONNRESET, timeout, proxy 5xx): back off and retry await new Promise(r => setTimeout(r, 500 * 2 ** i)); } }}Distinguish a broken connection worth retrying from a deliberate slowdown; a timeout is a broken attempt, a 429 is the server asking for space and should back off, not hammer.
Rotating geo and sessions
Because the targeting lives in the username, a different identity is a different proxy URL, which means a different agent. The efficient pattern is to cache one agent per identity so you keep the connection pool per session instead of rebuilding it:
const agents = new Map();
function agentFor(country, sid) { const key = `${country}:${sid ?? 'rotate'}`; if (!agents.has(key)) { const u = `${process.env.SHIFTER_USER}-country-${country}` + (sid ? `-sid-${sid}-ttl-600` : ''); const url = `http://${u}:${process.env.SHIFTER_PASS}@p.shifter.io:443`; agents.set(key, new HttpsProxyAgent(url)); } return agents.get(key);}
// per request:await axios.get(targetUrl, { httpsAgent: agentFor('de', 'job-42'), proxy: false });Give each logical unit of work its own sid and rotate between units, not mid-stream (sticky vs rotating covers the distinction), and map work to identities the way the load-balancing post describes.
Verify you are actually on the proxy
Before you benchmark or debug anything else, confirm the exit IP:
const res = await client.get('http://ip-api.com/json');console.log(res.data); // expect a residential IP in the targeted countryYour own IP means the agent is not being applied (with Axios, almost always a missing proxy: false). A hang means local egress is blocked. Both are covered in the timeout diagnosis guide.
FAQ
Why does the Axios proxy option not work with my HTTPS proxy?
Axios’s built-in proxy option does not reliably tunnel HTTPS with authentication. Use an httpsAgent built from https-proxy-agent and set proxy: false so Axios stops trying to handle it. That combination is the reliable path and fixes the “it returns my real IP” symptom.
Do I need https-proxy-agent if I use native fetch?
No. Node 18+ fetch is backed by undici, which has its own ProxyAgent you pass as the dispatcher (or set globally with setGlobalDispatcher). https-proxy-agent is for Axios, Got, and the built-in http/https modules.
Why does import got from 'got' throw in my project?
Got is pure ESM since v12, so it needs an ESM project ("type": "module") or a dynamic import(). On CommonJS, stay on Got v11 or use Axios/undici instead. This is a module-system issue, not a proxy one.
How do I rotate IPs per request in Node.js?
Vary the proxy username, which means a different proxy URL and a different agent. Cache one agent per identity in a Map so each session keeps its own connection pool, and pick the agent per request. Omit the sid in the username to rotate on every new connection.
Axios, Got, or fetch for scraping?
All three work. Native fetch + undici has zero extra dependencies on modern Node; Got has ergonomic retries and streams; Axios is ubiquitous and familiar but needs the proxy: false fix. Choose on ergonomics and existing dependencies, the proxy setup and traps above apply to all three.
The bottom line
Node.js plus residential proxies is quick to set up once you know the one non-obvious rule: with Axios, use an https-proxy-agent and set proxy: false, never the built-in proxy option; with Got, put the agent in agent.https; with native fetch, pass an undici ProxyAgent as the dispatcher. Then reuse the agent so connections stay warm, bound your concurrency because the event loop will not, handle async errors so a bad request retries instead of crashing, and vary the proxy username to change geo or session.
Get that right and Node handles concurrent collection as well as anything. Point it at the residential gateway, and remember that pool quality decides how often you retry at all (IP reputation). The pricing page has the per-GB plans to test it against your own targets.