Node has three HTTP clients people actually use, axios, got, and the fetch that is now built into the runtime, and every one of them wires a proxy differently. That is the whole difficulty. None of them take a plain proxy string the way you would hope, and the one that comes closest, axios, hides a flag that will silently undo your work. This guide covers all three, plus SOCKS5, authentication, rotating a pool, and the verification step that tells you the proxy is actually carrying your traffic.
We run a proxy network, so we read a lot of Node scraping code, and the same handful of mistakes show up every week: an axios agent that gets overridden by an environment variable, a require that returns undefined because the package switched to a named export, a fetch call that quietly goes out on the real IP because there is no proxy option to set. Every example below is copy-paste runnable. Where one needs a live proxy, pull a fresh one from our free proxy API, which returns real, recently checked endpoints without a key.
How do you use a proxy with Node.js?
The pattern that works across axios and the built-in https module is a proxy agent: build one with new HttpsProxyAgent('http://user:pass@host:port') from the https-proxy-agent package and pass it as httpsAgent. For the native fetch, use undici's ProxyAgent with setGlobalDispatcher. For got, use an agent map built from hpagent. Node routes HTTP through a pluggable connection object, and the proxy lives in that object rather than in a single option.
The agent model, and why Node needs one
In Node, an HTTP client does not open sockets itself. It hands the work to an http.Agent (or https.Agent), which manages the connection pool and the actual dial. A proxy agent is just an agent whose dial goes to the proxy first and opens a CONNECT tunnel to the real target through it. This is why almost every Node proxy library ships an agent rather than a config option: it slots into the place Node already reserved for connection handling. Once you have the mental model of "the proxy is an agent," the differences between axios, got, and fetch shrink to where each one lets you plug that agent in.
Install the one library that covers the common case:
npm install https-proxy-agent
Despite the name, HttpsProxyAgent handles both http:// and https:// target URLs. The https in the name refers to it tunneling HTTPS through the proxy with CONNECT, not to the proxy itself needing to be HTTPS. A plain HTTP proxy carries your HTTPS traffic fine, so new HttpsProxyAgent('http://proxy:port') used against an https:// target is the normal, correct combination even though the schemes look mismatched.
axios
axios is the most common client and the one with the most footguns, so it is worth getting exactly right. It has a built-in proxy option:
const axios = require('axios');
const res = await axios.get('https://httpbin.org/ip', {
proxy: {
protocol: 'http',
host: '203.0.113.7',
port: 8080,
auth: { username: 'user', password: 'pass' }, // sets Proxy-Authorization
},
});
console.log(res.data);
That works, and for https:// targets axios opens a CONNECT tunnel and performs TLS end to end with the origin. But the built-in option has two limits worth knowing: it does not speak SOCKS, and axios reads the standard proxy environment variables (HTTP_PROXY, HTTPS_PROXY, NO_PROXY) by default, so a variable set in your shell or CI can proxy you without your knowledge. The more predictable route, and the one most production code uses, is a proxy agent:
const axios = require('axios');
const { HttpsProxyAgent } = require('https-proxy-agent');
const agent = new HttpsProxyAgent('http://user:[email protected]:8080');
const res = await axios.get('https://httpbin.org/ip', {
httpAgent: agent,
httpsAgent: agent,
proxy: false, // stop axios adding its own proxy/env handling on top
timeout: 15000,
});
console.log(res.data);
The load-bearing line is proxy: false. When you supply your own agent, you want axios to route through that agent and nothing else. Leaving proxy unset lets axios layer its built-in proxy logic and environment variables over your agent, and the two quietly fight, which is the single most common "my axios proxy is being ignored" report we see. Set proxy: false whenever you pass a custom httpsAgent.
One more axios-era gotcha: if you copied an import like const HttpsProxyAgent = require('https-proxy-agent') from an older tutorial, it will throw HttpsProxyAgent is not a constructor on current versions. The package switched from a default export to a named export in version 6, so the current, correct form is const { HttpsProxyAgent } = require('https-proxy-agent').
got
got is pure ESM since version 12, so it is import, never require. Its docs point you at hpagent for proxies, because unlike the older tunnel package it keeps the internal sockets alive to be reused. Proxies plug into got through its agent option, which is a map keyed by protocol:
import got from 'got';
import { HttpsProxyAgent } from 'hpagent';
const res = await got('https://httpbin.org/ip', {
agent: {
https: new HttpsProxyAgent({
keepAlive: true,
keepAliveMsecs: 1000,
maxSockets: 256,
maxFreeSockets: 256,
scheduling: 'lifo',
proxy: 'http://user:[email protected]:8080',
}),
},
});
console.log(res.body);
Two details from got's own documentation matter here. First, hpagent ships both HttpProxyAgent and HttpsProxyAgent, and the key you set in the agent map has to match the target scheme: agent.https needs HttpsProxyAgent, agent.http needs HttpProxyAgent. Second, got's built-in HTTP/2 client does not support proxies, so if you enable http2: true you lose proxy support. For proxied work, stay on got's default HTTP/1.1 path.
Native fetch (undici)
Since Node 18 there is a global fetch, and the first thing people discover is that it has no proxy option. fetch(url, { proxy: ... }) does nothing. The global fetch is built on undici, and the proxy lives in undici's dispatcher rather than in a fetch option. The dispatcher API is not exposed as a built-in module, so install undici to get it:
npm install undici
The cleanest approach sets a global dispatcher, after which every fetch in the process is proxied:
import { ProxyAgent, setGlobalDispatcher } from 'undici';
const proxyAgent = new ProxyAgent('http://203.0.113.7:8080');
setGlobalDispatcher(proxyAgent);
const res = await fetch('https://httpbin.org/ip'); // now routed through the proxy
console.log(await res.json());
setGlobalDispatcher from the installed undici controls the runtime's built-in fetch because both reference the same global dispatcher slot. If you would rather scope the proxy to one call, undici accepts a per-request dispatcher, though note this option is not part of the standard fetch type definitions and TypeScript may flag it:
import { ProxyAgent } from 'undici';
const proxyAgent = new ProxyAgent('http://203.0.113.7:8080');
const res = await fetch('https://httpbin.org/ip', { dispatcher: proxyAgent });
Authenticated proxies work a little differently in undici. Rather than reading user:pass from the URL, the documented path is a token holding a full Proxy-Authorization header you build yourself:
const proxyAgent = new ProxyAgent({
uri: 'http://203.0.113.7:8080',
token: `Basic ${Buffer.from('user:pass').toString('base64')}`,
});
SOCKS5 proxies
None of the agents above speak SOCKS. For a SOCKS4 or SOCKS5 proxy, add one package:
npm install socks-proxy-agent
It produces an agent you drop into axios, got, or the https module exactly like the HTTP one:
const axios = require('axios');
const { SocksProxyAgent } = require('socks-proxy-agent');
const agent = new SocksProxyAgent('socks5h://user:[email protected]:1080');
const res = await axios.get('https://httpbin.org/ip', {
httpAgent: agent,
httpsAgent: agent,
proxy: false,
timeout: 15000,
});
console.log(res.data);
The h in socks5h is not decorative. socks-proxy-agent parses the URL scheme and, for socks5h:// (and socks4a://), leaves DNS resolution to the proxy; for plain socks5:// (and socks4://) your machine resolves the hostname first and sends the proxy an IP. That means socks5:// leaks every hostname you visit to your local resolver and fails on names that only resolve inside the proxy's network. For privacy or scraping, socks5h:// is almost always the right pick. We go deeper on the split in what is a SOCKS5 proxy.
Rotating across a pool
One IP sending every request is the exact shape a rate limiter watches for. Spread the load across a list and build a fresh agent per attempt, retrying on a different proxy when one fails:
const axios = require('axios');
const { HttpsProxyAgent } = require('https-proxy-agent');
const POOL = [
'http://203.0.113.7:8080',
'http://203.0.113.24:3128',
'http://198.51.100.14:8080',
];
async function getRotating(url, tries = 4) {
for (let i = 0; i < tries; i++) {
const proxy = POOL[Math.floor(Math.random() * POOL.length)];
const agent = new HttpsProxyAgent(proxy);
try {
return await axios.get(url, {
httpAgent: agent,
httpsAgent: agent,
proxy: false,
timeout: 15000,
});
} catch (err) {
continue; // dead or blocked exit, try the next one
}
}
throw new Error(`all ${tries} proxies failed for ${url}`);
}
const res = await getRotating('https://httpbin.org/ip');
console.log(res.data);
The load-bearing part is retrying with a different proxy on failure, because on any pool some exits are always down. You do not have to maintain the list by hand either. Our free proxy API returns a fresh pool as plain text you can split straight into that array:
const raw = (await axios.get('https://hproxy.com/api/proxy-list', {
params: { format: 'txt', protocol: 'http', recent: 'true', limit: 50 },
})).data;
const POOL = raw.trim().split('\n').map((p) => `http://${p}`);
For production, a rotating gateway that hands you one endpoint and a fresh residential IP per request removes list management entirely, but rotating a list by hand is the best way to understand what that gateway does for you.
Verify the exit IP
Never assume a proxy is working. Confirm the target sees the proxy's address and not yours, and do it before you trust an exit with real traffic:
const axios = require('axios');
const { HttpsProxyAgent } = require('https-proxy-agent');
const agent = new HttpsProxyAgent('http://203.0.113.7:8080');
const real = (await axios.get('https://httpbin.org/ip')).data.origin;
const viaProxy = (await axios.get('https://httpbin.org/ip', {
httpsAgent: agent,
proxy: false,
timeout: 15000,
})).data.origin;
console.log('real: ', real);
console.log('proxy:', viaProxy);
if (real === viaProxy) throw new Error('proxy is not changing your IP');
If the two IPs match, the request never went through the proxy, usually because proxy: false was missing and an environment variable or axios's own handling took over. It is also worth checking what headers arrive at the target, because a transparent proxy can forward your real address in X-Forwarded-For even while origin looks changed. Our proxy checker runs that whole battery, exit IP, anonymity grade, real geolocation, and latency, in one paste if you would rather not script it.
Where to go from here
The two habits that separate Node proxy code that works in a demo from code that runs unattended are the same in every client: pass an explicit timeout so a silent proxy cannot hang the event loop, and verify each exit before you trust it. For a live pool to test against, the free proxy list re-verifies its entries every few minutes, and the free API hands them to you with no key.
From here, the Python requests guide is the closest sibling to this one if your stack spans both languages, the cURL guide is the shell-side reference for quick one-off tests, and proxies for web scraping covers choosing the right proxy type and the request hygiene that no client library can add for you. When a project graduates from experiments to production, get IPs nobody else is burning on our paid pools.
Sources
- got documentation: proxy tips: the
hpagentrecommendation, theagentmap keyed by protocol, and the note that got's HTTP/2 client has no proxy support. - undici: ProxyAgent API:
setGlobalDispatcher, the per-requestdispatcheroption, and thetokenform of proxy authentication. - axios request config: the built-in
proxyobject shape and thehttpAgent/httpsAgentfields.