Guide

How to Use Proxies With Node.js: fetch, axios and HTTP_PROXY, Tested

Node.js proxy setup, tested: an undici dispatcher for fetch, HTTP_PROXY and NODE_USE_ENV_PROXY, axios proxy: false and its HTTPS fix in 1.16.1, the 407 errors.

HProxy Team··Updated September 27, 2026·11 min read
HProxy.Guide

Free proxies won't hold up here.

Shared datacenter IPs get flagged and dropped fast. When it has to hold, gaming, streaming, accounts, you need mobile and residential IPs that read as a real device, from $0.44/GB, pay as you go.

Proxies for Web Scraping→

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 fetch and ProxyAgent from undici and pass the agent as the dispatcher, fetch(url, { dispatcher: new ProxyAgent('http://user:pass@host:port') }). The fetch built into Node ignores a proxy option and HTTPS_PROXY.
  • axios: the proxy option and HTTPS_PROXY work, 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 setupWhat 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.
Our own console window: the Node proxy lab of 27 September 2026 on Node 22.19, undici 6.28.1 and axios 1.13.2. Node fetch went direct with a proxy option, with HTTPS_PROXY, and with NODE_USE_ENV_PROXY=1. The undici ProxyAgent and EnvHttpProxyAgent went through a CONNECT tunnel. A missing or wrong login gave Proxy response (407) !== 200 when HTTP Tunneling. axios sent the https URL to the proxy as a plain GET and returned the answer of the proxy itself, and skipped a custom httpsAgent unless proxy was false.
Captured on our own machine on 27 September 2026: Node 22.19 printing the saved results of our second run. The lab password is masked and ports read <port>.

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

Frequently asked questions

Does Node's built-in fetch use a proxy?
Not by itself. In our test on Node 22.19, fetch ignored a proxy option, HTTPS_PROXY, and HTTPS_PROXY together with NODE_USE_ENV_PROXY=1; every request went straight to the site. Import fetch and ProxyAgent from the undici package and pass the agent as the dispatcher: fetch(url, { dispatcher: new ProxyAgent('http://user:pass@host:port') }). A dispatcher from undici 6 also worked with the fetch built into Node 22.19, but undici 8, which npm installs today, is not made for the built-in fetch of Node 22 and 24.
Does Node.js read HTTP_PROXY and HTTPS_PROXY?
axios does, by default. Node's own fetch and https module do not until you switch the feature on. With NODE_USE_ENV_PROXY=1, fetch follows them from Node 22.21 or 24.0; the https module needs 22.21 or 24.5, which also added --use-env-proxy. With undici, setGlobalDispatcher(new EnvHttpProxyAgent()) makes fetch follow HTTP_PROXY, HTTPS_PROXY and NO_PROXY; in our test with undici 6.28.1 it also skipped the proxy for a NO_PROXY host. undici 8 needs Node 22.19 or newer, so on Node 20 install undici 7.
Why does axios ignore my httpsAgent?
Because HTTPS_PROXY or HTTP_PROXY is set and the proxy option is left unset. In our test, axios 1.13.2 then used the environment proxy and never called the custom httpsAgent. With proxy: false it used the agent. Set proxy: false whenever you pass your own proxy agent.
Is the axios proxy option safe for HTTPS sites?
Only from axios 1.16.1, released on 13 May 2026. Before that, axios sent an HTTPS request to the proxy as plain text, with the full URL, the headers and the proxy password. Our lab proxy answered such a request itself, and axios 1.13.2 returned that answer as if it came from the site. Since 1.16.1 axios opens a CONNECT tunnel; check your version with npm ls axios.
Why do I get HttpsProxyAgent is not a constructor?
The line const HttpsProxyAgent = require('https-proxy-agent') is the version 5 style. Since version 6 the package has a named export, so write const { HttpsProxyAgent } = require('https-proxy-agent'). Since version 8 the package is ESM only. From version 9, require() can load it on Node 22.12 and 20.19 or newer, and returns an object whose HttpsProxyAgent key is the class. Version 8.0.0 allows only import, so require() fails there with ERR_PACKAGE_PATH_NOT_EXPORTED; use import() or update to 9.
What does Proxy response (407) !== 200 when HTTP Tunneling mean?
The proxy refused the login when undici asked it to open the tunnel. fetch reports only fetch failed, then Request was cancelled., and this message sits one level further down in error.cause.cause. Check the user name and password, and write special characters in the proxy URL encoded, for example @ as %40.

Proxies that don't die mid-job

Residential, ISP, datacenter and mobile, verified by the same engine that runs tens of millions of checks. They read as a real device and hold up under load. Pay as you go, and your balance never expires. $0.44/GB is the 2,000 GB+ rate; a single gigabyte is $0.50/GB, with no minimum order.

129M+ proxy checks run · 100+ countries · HTTP / HTTPS / SOCKS · re-checked every few minutes · no signup

HProxy.

Honest guides and comparisons on proxies, scraping and staying unblocked, from the team that runs the network.

RSS feed