In Node.js, a proxy is not one setting. Each HTTP client decides for itself whether it uses one, and the built-in fetch ignores the places people usually put it. We tested this on 27 September 2026 with Node 22.19, undici 6.28.1 and axios 1.13.2, against proxies we ran on our own machine. The target was a made-up host that only our proxy could reach, so each request either went through the proxy or failed on the spot. We ran all 27 setups twice and got the same result both times.
The short version:
- fetch: import
fetchandProxyAgentfrom undici and pass the agent as the dispatcher,fetch(url, { dispatcher: new ProxyAgent('http://user:pass@host:port') }). The fetch built into Node ignores aproxyoption andHTTPS_PROXY. - axios: the
proxyoption andHTTPS_PROXYwork, but check the version first. Before 1.16.1, axios sent HTTPS requests to the proxy in plain text. - HTTP_PROXY: axios reads it. The fetch and https module built into Node do not, unless you set
NODE_USE_ENV_PROXY=1: fetch follows it from Node 22.21 or 24.0, the https module from 22.21 or 24.5.
What each client did with a proxy
| Client and setup | What happened |
|---|---|
Node fetch, with a proxy option. | Ignored. The request went direct. |
Node fetch, with HTTPS_PROXY set. | Ignored. The request went direct. |
Node fetch, HTTPS_PROXY and NODE_USE_ENV_PROXY=1 on Node 22.19. | Ignored. That version has no built-in proxy support. |
undici fetch, dispatcher: new ProxyAgent(url). | Went through the proxy in a CONNECT tunnel. |
Node fetch, a ProxyAgent dispatcher from undici 6. | Went through the proxy in a CONNECT tunnel. |
Node fetch after setGlobalDispatcher(new ProxyAgent(url)). | Went through the proxy in a CONNECT tunnel. |
Node fetch after setGlobalDispatcher(new EnvHttpProxyAgent()). | Followed HTTPS_PROXY, and skipped it for a NO_PROXY host. |
https.get, with HTTPS_PROXY set. | Ignored. The request went direct. |
axios, with HTTPS_PROXY set. | Used it, as a plain request with the full URL. |
axios, with the proxy option. | A plain request with the full URL, password included. |
axios, proxy: false and HTTPS_PROXY set. | Ignored the variable. The request went direct. |

fetch: give it a dispatcher
The fetch built into Node runs on undici, but Node does not expose the undici classes that handle proxies. Install the package, and import fetch from it together with ProxyAgent, as the undici documentation does:
npm install undici
import { fetch, ProxyAgent } from 'undici';
const proxy = new ProxyAgent('http://user:pass@203.0.113.7:8080');
const res = await fetch('https://httpbin.org/ip', { dispatcher: proxy });
console.log(await res.text());
In our lab, the fetch of undici 6.28.1 with this dispatcher went through the proxy in a CONNECT tunnel. So did the fetch built into Node 22.19, given a dispatcher from the same undici, and setGlobalDispatcher(proxy) did it for every fetch in the process.
Which undici you get matters. npm install undici gives version 8 today, and version 8 needs Node 22.19 or newer. Its documentation says the npm package "does not replace the built-in globals". An undici 8 dispatcher and the fetch built into Node 22 or 24 do not fit together: undici 8 checks each request for a handler of its new kind, and its own pull request describes the result as "runtime failures like invalid onRequestStart method". We did not run undici 8. Importing fetch from undici, as above, gives you a matching pair. setGlobalDispatcher is bridged in undici 8 for the built-in fetch, according to its migration guide.
http:// addresses. With undici 6.28.1, an http:// address went through a CONNECT tunnel too. Since undici 8.7.0, released on 4 July 2026, ProxyAgent sends an http:// address to the proxy as a plain request with the full URL, unless you set proxyTunnel: true.
The login. undici takes it from token first, then auth, then the user name and password in the proxy URL. It URL-decodes the URL form, so a password with an @ is written %40. In our lab, http://labuser:lab%40pass@127.0.0.1:<port> passed. Here is the token form, with a full header value that you build yourself:
const proxy = new ProxyAgent({
uri: 'http://203.0.113.7:8080',
token: 'Basic ' + Buffer.from('user:pass').toString('base64'),
});
The errors. fetch reports only "fetch failed". The reason sits further down the chain of causes, so print all of it:
try {
await fetch('https://httpbin.org/ip', { dispatcher: proxy });
} catch (err) {
for (let e = err; e; e = e.cause) console.error(e.message);
}
With a missing or wrong login, that printed "fetch failed", then "Request was cancelled.", then "Proxy response (407) !== 200 when HTTP Tunneling". With nothing listening on the proxy port, it printed "fetch failed" and "connect ECONNREFUSED". Our 407 guide covers the login side.
We measured undici 6.28.1. The current major version is 8, released in April 2026. Its documentation describes the same login options, and the http:// change above.
HTTP_PROXY: who reads it
axios reads HTTP_PROXY, HTTPS_PROXY and NO_PROXY by itself. The fetch and https module built into Node ignored all three on Node 22.19.
Node added built-in support in two steps. NODE_USE_ENV_PROXY=1 arrived in 24.0 and 22.21 and covers fetch; the http and https modules, and the --use-env-proxy flag, followed in 24.5 and 22.21. Once switched on, Node "parses the HTTP_PROXY, HTTPS_PROXY and NO_PROXY environment variables during startup". It is off by default, and the documentation marks it as still in active development. Node's HTTP documentation warns that it "is not an anonymity or traffic-hiding feature".
undici can read the variables for you:
import { EnvHttpProxyAgent, setGlobalDispatcher } from 'undici';
setGlobalDispatcher(new EnvHttpProxyAgent());
// every fetch in this process now follows HTTP_PROXY, HTTPS_PROXY and NO_PROXY
In our lab, with undici 6.28.1 and the fetch built into Node 22.19, it followed HTTPS_PROXY, and it went direct for a host listed in NO_PROXY. When both variables are set, HTTP_PROXY serves http:// addresses and HTTPS_PROXY serves https:// addresses. undici 8 needs Node 22.19 or newer, so on Node 20 install undici 7.
axios: check the version first
Run npm ls axios before anything else. Before version 1.16.1, axios sent an https:// request to the proxy as a plain request, with the full URL in the request line. The proxy saw the address, the headers, the body and the proxy password. In our lab, axios 1.13.2 did this with the proxy option and with HTTPS_PROXY. Our proxy answered the request itself, and axios returned "answered by the lab proxy" with status 200, as if the site had said it.
The axios changelog for 1.16.1, released on 13 May 2026, calls this a "Proxy Cleartext Leak". Since then, axios opens a CONNECT tunnel for HTTPS addresses and speaks TLS with the site through it. The current version is 1.20.0.
The proxy option itself:
import axios from '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' },
},
});
console.log(res.data);
Why proxy: false matters. axios uses HTTPS_PROXY even when you pass your own agent. In our lab, with the variable set and proxy left unset, axios 1.13.2 never called a custom httpsAgent. With proxy: false, it used the agent and ignored the variable. So when you bring your own proxy agent, add proxy: false:
import axios from 'axios';
import { HttpsProxyAgent } from 'https-proxy-agent';
const agent = new HttpsProxyAgent('http://user:pass@203.0.113.7:8080');
const res = await axios.get('https://httpbin.org/ip', { httpsAgent: agent, proxy: false });
In that second example, the agent line follows the https-proxy-agent README, and httpsAgent with proxy: false comes from the axios README; we did not run https-proxy-agent. axios also honoured NO_PROXY for our target. A wrong password gave "Request failed with status code 407", and a closed proxy port gave "connect ECONNREFUSED".
https-proxy-agent and the require line
https-proxy-agent tunnels HTTPS through an HTTP proxy for clients that take a Node agent: the https module, node-fetch, and axios before 1.16.1. Version 8.0.0, released on 11 March 2026, made the package ESM only. Version 9 needs Node 20 or newer, and the current version is 9.1.0.
// ES modules
import { HttpsProxyAgent } from 'https-proxy-agent';
// CommonJS, version 9 or later, on Node 22.12 and 20.19 or newer
const { HttpsProxyAgent } = require('https-proxy-agent');
From version 9, the CommonJS line works, because those Node versions let require() load an ES module and return its named exports. We checked this on Node 22.19 with a stand-in package shaped like version 9. Version 8.0.0 is different. Its package file allows only import, and with a stand-in of that shape both require() lines failed with ERR_PACKAGE_PATH_NOT_EXPORTED, "No "exports" main defined", while import() worked. Version 9 changed this, to "remove unnecessary imports restriction", in the words of its changelog. The older line, const HttpsProxyAgent = require('https-proxy-agent'), is the version 5 style, and it failed with "HttpsProxyAgent is not a constructor". require() returns an object whose HttpsProxyAgent key is the class, not the class itself. A wrong capital, as in HttpsproxyAgent, fails the same way.
On an older Node version, or with version 8.0.0, load it with await import('https-proxy-agent'), or stay on version 7, the last CommonJS release.
got
The got documentation recommends hpagent, because it "allows keeping the internal sockets alive to be reused". A shortened version of its example, with our proxy address:
import got from 'got';
import { HttpsProxyAgent } from 'hpagent';
const res = await got('https://httpbin.org/ip', {
agent: {
https: new HttpsProxyAgent({ keepAlive: true, proxy: 'http://user:pass@203.0.113.7:8080' }),
},
});
console.log(res.body);
It also says the HTTP/2 client of got has no proxy support, so leave http2 off. We did not run got.
SOCKS5: socks5 or socks5h
The fetch built into Node and undici 6 do not speak SOCKS. The current undici documentation handles socks5:// addresses in ProxyAgent. For the https module and axios, socks-proxy-agent gives you an agent:
import axios from 'axios';
import { SocksProxyAgent } from 'socks-proxy-agent';
const agent = new SocksProxyAgent('socks5h://user:pass@203.0.113.7:1080');
const res = await axios.get('https://httpbin.org/ip', { httpAgent: agent, httpsAgent: agent, proxy: false });
We did not run socks-proxy-agent; the example uses the address form from its documentation. The h matters. In the package source, socks5:// resolves host names on your machine, so your own resolver sees every host you visit. socks5h:// leaves the lookup to the proxy, and a bare socks:// means the same as socks5h://. Our guide to SOCKS5 proxies explains the difference.
Check that the proxy carries the traffic
Compare the address a site sees with and without the proxy:
import { fetch, ProxyAgent } from 'undici';
const proxy = new ProxyAgent('http://user:pass@203.0.113.7:8080');
const direct = await (await fetch('https://httpbin.org/ip')).json();
const proxied = await (await fetch('https://httpbin.org/ip', { dispatcher: proxy })).json();
console.log(direct.origin, proxied.origin);
if (direct.origin === proxied.origin) throw new Error('the proxy is not changing your address');
If both lines print the same address, the request never used the proxy. We did not run this check, or the rotation below, because our lab never leaves our own machine. Our proxy checker tests a pasted proxy for status, speed, anonymity, country and network.
Rotate through a pool
Build a fresh dispatcher for each try, and move to another proxy when one fails. Our free proxy API returns working proxies as plain text, one per line:
import { fetch, ProxyAgent } from 'undici';
const list = await (await fetch('https://hproxy.com/api/proxy-list?format=txt&protocol=http&limit=50')).text();
const pool = list.trim().split('\n').map((p) => `http://${p}`);
async function fetchRotating(url, tries = 4) {
for (let i = 0; i < tries; i++) {
const proxy = new ProxyAgent(pool[Math.floor(Math.random() * pool.length)]);
try {
return await fetch(url, { dispatcher: proxy, signal: AbortSignal.timeout(15000) });
} catch {
// a dead or blocked exit: try another one
}
}
throw new Error(`all ${tries} proxies failed for ${url}`);
}
Leave out recent=true here. That option adds proxies that went quiet in the last 48 hours, so it makes the list longer, not fresher. Public proxies see everything that is not encrypted, so send nothing private through them. For production, a rotating gateway with a new residential IP on each request removes the list work entirely.
Where to go from here
Two habits separate proxy code that works in a test from code that runs unattended. Set a timeout on every request, and check the exit before you trust it. For a live pool to test against, the free proxy list is checked every few minutes.
The Python requests guide is the closest sibling to this one, and the cURL guide is the quickest test from a terminal. Proxies for web scraping covers choosing a proxy type. For pages that only render in a browser, proxies with Playwright is the Node path with a real browser. When a project moves to production, our paid pools give you addresses that nobody else is using up. Services that order their own proxies can use the API documentation for keys, orders and the webhooks that fire when a plan is ready.
Sources
- axios: the changelog (v1.16.1, 13 May 2026, "Proxy Cleartext Leak"), the README on the proxy option, and the HTTP adapter source of v1.13.2 and v1.16.1, read 27 September 2026.
- undici: ProxyAgent and EnvHttpProxyAgent, and the 6.28.1 source of the 407 message, read 27 September 2026.
- undici versions: the guides undici and the built-in fetch and migrating from v7 to v8, the handler check in 8.11.2, pull request 4827, the 8.7.0 release and the 8.11.2 ProxyAgent docs, read 27 September 2026.
- Node.js documentation: built-in proxy support, --use-env-proxy and loading ES modules with require(), read 27 September 2026.
- https-proxy-agent: the changelog, and the README of version 5 and version 6.
- socks-proxy-agent source and the proxy tips of got, read 27 September 2026.


