Node is the one major runtime where setting HTTPS_PROXY does nothing. There is no proxy support in the built-in HTTP clients, so a request you believe is proxied leaves from your own address with no warning and no error. This guide gives the working configuration for each client, and the two failures that catch people.
Every result below was tested against a local proxy that logs each request, so “it worked” means the proxy recorded the connection, not that the code ran without throwing.
#The environment variables do not apply
Verified on Node 24.2.0: a request made with HTTPS_PROXY set reached the destination directly. Nothing was logged by the proxy.
HTTPS_PROXY=http://127.0.0.1:8888 node -e "fetch('https://api.github.com/')"
# The proxy logged nothing. The request went direct.
This is a design decision rather than a bug. Node’s HTTP stack has never read the proxy environment, so tools that work everywhere else silently bypass your proxy here. If your deployment sets those variables and expects them to be honoured, the environment-variable guide explains what other runtimes do with them.
#Global fetch: use a global dispatcher
Node’s global fetch is built on undici, and undici routes through a dispatcher. Install undici, then replace the global one:
import { ProxyAgent, setGlobalDispatcher } from 'undici';
setGlobalDispatcher(new ProxyAgent('http://user:[email protected]:8000'));
const res = await fetch('https://api.github.com/'); // proxied
This is process-wide. Every fetch in the process now uses the proxy, including calls made by libraries you did not write. That is usually what you want for a scraper and rarely what you want inside a larger application.
#The per-call dispatcher trap
The obvious way to scope it to one request does not work on the global fetch:
// Fails on Node 24.2.0
await fetch(url, { dispatcher: new ProxyAgent(PROXY) });
// TypeError: fetch failed
// cause: UND_ERR_INVALID_ARG
The error message says fetch failed, which reads like a network problem. It is not. The global fetch rejects the dispatcher option outright, and the request never reaches the proxy.
Importing fetch from undici instead accepts it, and the proxy logged the connection:
import { fetch, ProxyAgent } from 'undici';
const res = await fetch(url, { dispatcher: new ProxyAgent(PROXY) }); // works
The two functions have the same name and different options. If you want per-request proxy selection, import undici’s fetch explicitly rather than relying on the global.
#What each client needs
| Client | How to proxy it | Scope |
|---|---|---|
Global fetch |
setGlobalDispatcher(new ProxyAgent(url)) |
Whole process |
undici fetch |
dispatcher option per call |
Per request |
undici request |
dispatcher option per call |
Per request |
axios |
httpsAgent plus proxy: false |
Per request or instance |
axios, built-in |
proxy: { protocol, host, port } |
Per request or instance |
got |
agent: { https: agent } |
Per request or instance |
node:http / node:https |
agent option |
Per request |
Tested versions: Node 24.2.0, undici 8.10.0, axios 1.20.0, got 15.1.0, https-proxy-agent 9.1.0. All five working rows were confirmed by the proxy log.
#axios: two ways, and one that surprises people
import axios from 'axios';
import { HttpsProxyAgent } from 'https-proxy-agent';
// Option 1: an agent. Set proxy:false or axios will try to do both.
const r1 = await axios.get(url, {
httpsAgent: new HttpsProxyAgent('http://user:[email protected]:8000'),
proxy: false,
});
// Option 2: axios's own proxy option
const r2 = await axios.get(url, {
proxy: { protocol: 'http', host: 'gateway.example', port: 8000,
auth: { username: 'user', password: 'pass' } },
});
Both were confirmed working. The proxy: false in the first is not optional decoration: without it axios applies its own proxy handling on top of the agent, and the two can conflict. Set one mechanism, not both.
#got
import got from 'got';
import { HttpsProxyAgent } from 'https-proxy-agent';
const res = await got(url, {
agent: { https: new HttpsProxyAgent('http://user:[email protected]:8000') },
});
Note the shape: agent is an object keyed by protocol. Set https for https:// targets and http for plain ones. Setting only https and then requesting an http:// URL silently goes direct, which is the same class of failure as the missing environment variables.
#The 403 that is not a proxy problem
One test returned 403 through a working proxy. The proxy logged the connection, so the request was proxied correctly. The destination refused it.
// undici's low-level request sends no User-Agent at all
const r = await request(url, { dispatcher: new ProxyAgent(PROXY) });
// 403
const r2 = await request(url, {
dispatcher: new ProxyAgent(PROXY),
headers: { 'user-agent': 'my-tool/1.0' },
});
// 200
GitHub’s API requires a user agent and refuses requests without one. Node’s low-level clients send very few headers by default, so this class of failure appears as soon as you leave the browser-shaped clients behind.
The habit worth building: when a proxied request fails, check whether the proxy saw it before you change any proxy setting. A refusal that reached the destination is a destination problem, and no amount of proxy configuration will fix it.
#node:http and node:https
The built-in modules take an agent, which is what the higher-level libraries wrap. Confirmed working, with the proxy logging the connection:
import https from 'node:https';
import { HttpsProxyAgent } from 'https-proxy-agent';
https.get('https://api.github.com/', {
agent: new HttpsProxyAgent('http://user:[email protected]:8000'),
headers: { 'user-agent': 'my-tool/1.0' },
}, res => {
console.log(res.statusCode);
res.resume();
});
Use node:https for https:// targets and node:http for plain ones. The agent classes are not interchangeable, and mixing them fails in ways that look like network errors.
#Reuse the agent: a measured difference
Creating an agent per request is the most common performance mistake here, and the cost is easy to measure. Five identical requests were sent twice, once through a single shared ProxyAgent and once with a new agent for each request. The proxy counted the tunnels it was asked to open:
| Approach | Requests | CONNECT tunnels opened |
|---|---|---|
One shared ProxyAgent |
5 | 1 |
A new ProxyAgent per request |
5 | 5 |
// Do this: create once, reuse
const agent = new ProxyAgent(PROXY);
for (const url of urls) {
const r = await request(url, { dispatcher: agent });
r.body.dump();
}
Each extra tunnel is a full TLS handshake to the destination through the proxy. On a slow residential path that is the dominant cost of a small request, and it is entirely avoidable.
The exception is deliberate rotation. If you want a different exit per task, hold one agent per identity and reuse each of them, rather than creating a fresh agent for every request.
#Proving the proxy is used
Do not infer this from the absence of errors. Ask something to report the address, and compare against the same call with no proxy:
import { fetch, ProxyAgent } from 'undici';
const viaProxy = await (await fetch('https://api.ipify.org',
{ dispatcher: new ProxyAgent(PROXY) })).text();
const direct = await (await fetch('https://api.ipify.org')).text();
console.log({ viaProxy, direct }); // identical means no proxy was applied
On a rotating endpoint this reports one exit node at one moment, not a property of the pool. Sample several times before concluding anything about a country.
#Reading the errors
Node reports proxy failures through error codes that name the layer that failed. Each row below was produced deliberately and observed:
| Code | Cause | Where to look |
|---|---|---|
ECONNREFUSED |
Nothing is listening on the proxy port | The port, or whether the proxy is running |
ENOTFOUND |
The proxy hostname does not resolve | A typo in the gateway host, or your DNS |
UND_ERR_CONNECT_TIMEOUT |
The proxy accepted nothing in time | Often the wrong scheme: https:// against an http:// proxy |
UND_ERR_INVALID_ARG |
An option the client does not accept | A dispatcher passed to the global fetch |
| HTTP 407 | The proxy rejected your credentials | The username format; see proxy authentication |
The third row is worth memorising. Writing https:// for a proxy that speaks plain HTTP produces a timeout rather than a protocol error, because the proxy is waiting for a request while your client waits for a TLS handshake. Neither side is wrong, and neither will speak first.
The distinction that matters across all of these: the first four are raised before the destination is contacted, so they are proxy or configuration faults. A 407 is the proxy refusing you. Anything with a status code from the destination, including a 403, means the tunnel worked.
#Practical notes
- Reuse the agent. Creating a
ProxyAgentper request discards connection reuse and adds a handshake to every call. - Set a timeout. None of these clients bounds the wait by default in a way you should rely on. An unbounded call on a hot path will eventually hang.
- Keep credentials out of logs. The proxy URL contains the password, and it is easy to log the whole configuration object during debugging.
- Failed requests still cost traffic. A 403 through a metered proxy is billable. See bandwidth billing.
#A note on SOCKS
Everything above uses an HTTP proxy. For a SOCKS5 endpoint the pattern is the same but the agent differs: socks-proxy-agent provides one that the built-in modules, axios and got all accept in the same agent position. undici takes a socket factory rather than an agent, so the integration is less direct there.
The hostname question applies here as it does everywhere else. Resolve the destination at the proxy rather than on your own machine, or you leak your queries to your local resolver and resolve from the wrong place. That distinction is the difference between socks5 and socks5h in other tools, and the equivalent option in Node is usually a flag on the agent. We have not tested the SOCKS path here, so verify it with the address comparison above rather than trusting the configuration to be correct.
#How this was tested
A local forward proxy on 127.0.0.1:8888 handled CONNECT and logged every request. Each client made one HTTPS request to the same destination. A configuration counts as working only where the proxy log recorded the connection; a call that succeeded without appearing in the log was treated as having gone direct. A control request through curl confirmed the proxy logged correctly before any Node test ran.
Tested on macOS on 29 August 2026, with the versions listed above. Client behaviour changes between releases, so re-run the address comparison on your own versions rather than assuming these results hold.