Node's global fetch ignores HTTP_PROXY and has no agent option. Set an undici ProxyAgent dispatcher, dodge the version-skew trap, and rotate cleanly.
· 13 min read · Integrations
Verified on Node v22.20.0 with undici 8.10.0, 2026-08-06.
Node's global fetch ignores HTTP_PROXY. A Node.js fetch proxy agent is
therefore something you construct, not something you configure: install
undici, build a ProxyAgent, attach it as a dispatcher. There is no flag,
no agent option and no warning — an unproxied fetch goes direct and says
nothing about it. HTTPS_PROXY and NO_PROXY are ignored on the same terms.
Every other HTTP client in your stack honors those variables. The one built
into the runtime does not.
Every code block below was executed against a local proxy before publication, on Node v22.20.0 (bundled undici 6.21.2) with undici 8.10.0 installed from npm. That pairing matters, and one section exists entirely because of it.
fetch in Node is undici behind a WHATWG-shaped facade. undici is bundled
with the runtime — you can see which copy with process.versions.undici — but
it is not exposed as a core module. There is no require('node:undici'); that
throws ERR_UNKNOWN_BUILTIN_MODULE, and the
open request to expose it has
been open since May 2022. So the first step is always the same:
npm install undici
The unit of configuration is a dispatcher. Node's fetch either uses the global dispatcher, or the one you hand it per request. There is no third option, no proxy string, and no environment variable that works without an opt-in flag on a recent enough runtime — see the version boundary below.
Two forms. Pick based on scope, not preference.
Process-wide, with setGlobalDispatcher. Every fetch in the process,
including calls made by libraries you did not write, goes through the proxy:
import { ProxyAgent, setGlobalDispatcher } from 'undici'
const agent = new ProxyAgent({
uri: 'http://gw.anonedge.com:823',
token: 'Basic ' + Buffer.from(`${process.env.PROXY_USER}:${process.env.PROXY_PASS}`).toString('base64')
})
setGlobalDispatcher(agent)
const res = await fetch('https://api.ipify.org/?format=json')
console.log(await res.json())
Scoped, with the per-request dispatcher option — and note that fetch
is imported from undici here, which is not a stylistic choice:
import { ProxyAgent, fetch } from 'undici'
const agent = new ProxyAgent('http://gw.anonedge.com:823')
const res = await fetch('https://api.ipify.org/?format=json', { dispatcher: agent })
console.log(await res.json())
ProxyAgent takes uri plus token, headers, requestTls, proxyTls,
clientFactory and proxyTunnel, along with the usual Agent options such
as connections and keepAliveTimeout. The
ProxyAgent reference
is short and worth reading once in full.
Here is the behavior that costs people an afternoon. On Node v22.20.0, whose bundled undici is 6.21.2, with undici 8.10.0 installed from npm:
| Call | Result |
|---|---|
setGlobalDispatcher(agent) then global fetch(url) |
Works. Request reaches the proxy with Proxy-Authorization set |
Global fetch(url, { dispatcher: agent }) |
Fails. TypeError: fetch failed, cause invalid onRequestStart method |
fetch imported from undici, with { dispatcher: agent } |
Works |
undici.request(url, { dispatcher: agent }) |
Works |
The global fetch is served by the copy of undici compiled into the runtime.
The dispatcher you constructed came from the copy in node_modules. When
those two disagree about the internal handler contract, the per-request
dispatcher path is where the disagreement surfaces — as a generic
fetch failed whose real cause is only visible on error.cause.
The rule that makes this go away:
setGlobalDispatcher when you want process-wide proxying, orfetch from undici when you want per-request dispatchers, so both
ends come from the same copy.Do not mix the runtime's fetch with a node_modules dispatcher and expect
it to hold across upgrades. Always read error.cause — fetch wraps
everything in the same opaque TypeError, and the cause is the only useful
part of it.
Scope that table correctly. It is one version pair, observed once. It is not a
claim about Node 22 in general, about undici 8 in general, or about any pairing
not listed. Treat it as a failure mode to recognize — fetch failed with
invalid onRequestStart method on error.cause — rather than as a version
boundary to plan around. The rule underneath it holds on every pairing, which
is why the rule and not the matrix is the thing to take away.
Three forms work. They are not equivalent operationally.
Credentials in the proxy URI. Shortest, and the one that leaks:
const agent = new ProxyAgent('http://YOUR_LOGIN:[email protected]:823')
The password is now inside a string that gets logged by connection-error
handlers, printed by debug middleware, serialized into crash reports, and
committed the moment someone hardcodes it "just for a test". Verified: this
form does send Proxy-Authorization: Basic <base64> correctly. It works. It
is still the worst of the three.
The token option. The credential is a value you construct, so it is
never part of a URL that anything else formats:
const agent = new ProxyAgent({
uri: 'http://gw.anonedge.com:823',
token: 'Basic ' + Buffer.from(`${process.env.PROXY_USER}:${process.env.PROXY_PASS}`).toString('base64')
})
token is the whole header value, scheme included. Forgetting the Basic
prefix is the single most common mistake here, and the failure it produces is
a plain 407, which sends people looking at their credentials rather than at
their string.
There is also an older auth option that takes the base64 payload without a
scheme. It is deprecated in favor of token. Do not start with it.
Read it from the environment or a secrets manager at startup and construct the
agent once. Do not template it into a URL that you then log. If you need to
print the agent's configuration during debugging, print uri and never
token. Rotating a leaked proxy credential is a support ticket and a
redeploy, both avoidable.
The proxy URI scheme and the target URI scheme are independent, and confusing them is the second most common failure on this topic.
http://gw.anonedge.com:823 describes how you talk to the proxy. Fetching
https://example.com/ through it means the proxy opens a CONNECT tunnel and
your TLS session is negotiated end-to-end with the target through that tunnel.
The proxy sees a hostname and a byte count; it does not see your request path,
headers or body, and it does not terminate your TLS.
The decision to tunnel belongs to the dispatcher, not to the URL. ProxyAgent
tunnels HTTPS targets automatically, and exposes proxyTunnel to force
tunneling for plain HTTP targets as well. requestTls configures the TLS
session to the target; proxyTls configures TLS to the proxy itself, which is
what you need only if the proxy endpoint is HTTPS.
If you are tempted to set rejectUnauthorized: false in requestTls to make
an error go away, stop. That disables verification of the target's
certificate, which is precisely the thing the tunnel exists to protect.
An Agent pools connections per origin and reuses them. That is the right
default for throughput and the wrong default for rotation.
Through a proxy, a reused connection is a reused tunnel, and a reused tunnel is a reused exit address. If your IP is not changing on a rotating port, pooling that defeats rotation is the first thing to check — before you suspect the gateway.
The pattern that works is one dispatcher per session, closed when the session ends:
import { ProxyAgent, fetch } from 'undici'
const TOKEN = 'Basic ' + Buffer.from(`${process.env.PROXY_USER}:${process.env.PROXY_PASS}`).toString('base64')
async function withFreshExit(url) {
const agent = new ProxyAgent({ uri: 'http://gw.anonedge.com:823', token: TOKEN })
try {
const res = await fetch(url, { dispatcher: agent })
return await res.text()
} finally {
await agent.close()
}
}
That trades throughput for identity, so do not use it for every request in a high-volume job. For work that needs one exit held across several requests, pin a sticky port instead and keep the agent alive for the life of the session:
const session = (port) => new ProxyAgent({ uri: `http://gw.anonedge.com:${port}`, token: TOKEN })
const a = session(10000) // worker 1 — one exit, held
const b = session(10001) // worker 2 — a different exit
Each port in the 10000-20000 range is its own session slot; two workers on
the same port share an address. Ports 823 (HTTP/HTTPS) and 824 (SOCKS5)
are the rotating ones. The
gateway reference and the
port list have the details.
When you do want environment-variable behavior — a corporate proxy, a CI
egress gateway, anything where the operator rather than the code decides —
EnvHttpProxyAgent reads HTTP_PROXY, HTTPS_PROXY and NO_PROXY and
routes accordingly:
import { EnvHttpProxyAgent, setGlobalDispatcher } from 'undici'
setGlobalDispatcher(new EnvHttpProxyAgent())
Two behaviors worth knowing, both confirmed by running them:
It reads the environment at construction time. Mutating process.env
after the agent exists changes nothing. Construct it after your config
loading, not before.
NO_PROXY bypasses to a direct connection, not to a fallback. A hostname
matching NO_PROXY is fetched directly, so if the host is only reachable
through the proxy it will fail with a DNS or connect error rather than
retrying through the proxy. The most common NO_PROXY complaint is a
matching rule that is too broad — a bare example.com entry also covering
api.example.com — quietly taking traffic off the proxy you are being billed
for. The matching rules differ between tools, and the full set of traps is in
EnvHttpProxyAgent and NO_PROXY semantics.
Node has been growing native support for the proxy environment variables, and
it is opt-in. Per the
Node.js enterprise network configuration guide,
NODE_USE_ENV_PROXY=1 works with fetch() from v22.21.0 or v24.0.0, and
with node:http and node:https from v22.21.0 or v24.5.0. The
command-line equivalent, --use-env-proxy, is documented from v22.21.0 or
v24.5.0 — so on a v24.0.x runtime the environment variable is the only door
in.
Those numbers are exact and unforgiving. Verified on Node v22.20.0 — one patch below the line:
NODE_USE_ENV_PROXY=1 HTTP_PROXY=http://127.0.0.1:3132 node app.js
# request went direct; the local proxy logged zero connections
No warning, no error. The request simply did not use the proxy. If you are relying on the built-in path, assert your runtime version at startup rather than trusting that it is "Node 22".
| Approach | Needs npm install | Scope | Minimum Node |
|---|---|---|---|
setGlobalDispatcher(new ProxyAgent(...)) |
Yes | Whole process | Any version with global fetch |
Per-request { dispatcher } via undici's fetch |
Yes | One call | Any version with global fetch |
EnvHttpProxyAgent |
Yes | Whole process, env-driven | Any version with global fetch |
NODE_USE_ENV_PROXY=1 for fetch() |
No | Whole process, env-driven | v22.21.0 or v24.0.0 |
NODE_USE_ENV_PROXY=1 for node:http |
No | Whole process, env-driven | v22.21.0 or v24.5.0 |
undici gained SOCKS5 support through
Socks5ProxyAgent,
introduced in v7.23.0 and still marked experimental. Constructing one
prints:
ExperimentalWarning: SOCKS5 proxy support is experimental and subject to change
ProxyAgent accepts socks5: and socks: URIs and delegates to it, so this
works:
import { Socks5ProxyAgent, fetch } from 'undici'
const agent = new Socks5ProxyAgent('socks5://YOUR_LOGIN:[email protected]:824')
const res = await fetch('https://api.ipify.org/?format=json', { dispatcher: agent })
One property makes SOCKS5 worth the experimental warning for scraping work: the documentation states that "DNS resolution is delegated to the proxy: target host names are sent to the proxy as domain names rather than being resolved locally." That keeps your target list out of your local resolver's logs, and it means the CDN edge you hit is chosen from the exit's position rather than yours. It is the same distinction that separates SOCKS5 from socks5h and its DNS leak in every other client — undici's implementation takes the remote-resolution side by default.
What I would actually do: use HTTP on 823 for anything that has to be
boring, and reach for SOCKS5 on 824 when remote DNS resolution is the
reason. NODE_DEBUG=undici:socks5-proxy prints the handshake if it is not
working.
fetch collapses everything into TypeError: fetch failed. The signal is on
error.cause.
invalid onRequestStart method — the version-skew case above. Your
dispatcher and your fetch came from different copies of undici.407 — the gateway rejected your credential. Check the Basic prefix
on token before you check the password. Confirm with
curl -x "http://LOGIN:[email protected]:823" https://api.ipify.org/.407 THREADS_EXHAUSTED — not a credential problem. You hit the
concurrent connection ceiling. connections on the agent caps your side of
it; see the gateway error codes.ECONNRESET / socket hang up — a connection died mid-flight. Through
a proxy this usually means the tunnel was torn down at the far end, which on
a rotating port can simply mean the exit went away.ECONNREFUSED to your proxy host — wrong port, or you are pointed at a
port class that does not exist. 823, 824 and 10000-20000 are the three.UND_ERR_CONNECT_TIMEOUT — the tunnel never came up. Distinguish this
from a slow target: it is a failure to reach the proxy, not a failure of the
request.CERT_HAS_EXPIRED or UNABLE_TO_VERIFY_LEAF_SIGNATURE — a TLS problem
with the target, surfacing through the tunnel. Do not disable
verification to make it stop.Axios has an httpsAgent option and a proxy option, and the trap is that
setting proxy alone does not tunnel HTTPS correctly in every configuration —
which is why so much Axios proxy code sets proxy: false and does the work
in an agent instead. Different library, same underlying question: which object
decides whether CONNECT happens. If you are moving code between the two,
the Axios proxy tutorial has the
gateway-specific form.
Native fetch is the smaller surface. If you are starting fresh in Node, one
ProxyAgent and one setGlobalDispatcher call is the whole integration.
Not by default. Node's global fetch ignores HTTP_PROXY, HTTPS_PROXY and
NO_PROXY unless you opt in with NODE_USE_ENV_PROXY=1 or --use-env-proxy,
which requires Node v22.21.0 or v24.0.0 for fetch(). On anything older, the
variables are silently ignored and your request goes direct. The portable
answer is an undici EnvHttpProxyAgent installed with setGlobalDispatcher.
Check error.cause. If it reads invalid onRequestStart method, the
dispatcher came from the undici in node_modules while fetch came from the
copy bundled into the runtime, and the two disagree on an internal contract.
Fix it by importing fetch from undici alongside ProxyAgent, or by using
setGlobalDispatcher instead of the per-request option.
Use the token option and pass the complete header value, including the
scheme: 'Basic ' + Buffer.from('user:pass').toString('base64'). Credentials
embedded in the proxy URI also work but end up in logs and crash reports. The
older auth option, which takes the base64 payload without a scheme, is
deprecated. Omitting the Basic prefix produces a plain 407.
Connection pooling. An undici Agent reuses connections per origin, and
through a proxy a reused connection is a reused tunnel to the same exit
address. Rotation happens when a new tunnel is opened. Construct a fresh
ProxyAgent per session and close() it when the session ends, or accept
pooling and pin sticky ports instead of expecting per-request rotation.
Yes. undici added Socks5ProxyAgent in v7.23.0; it is experimental and prints
an ExperimentalWarning on construction. ProxyAgent also accepts socks5:
URIs and delegates to it. The practical reason to choose it is that DNS
resolution is delegated to the proxy, so hostnames are resolved at the exit
rather than locally. Use port 824 for SOCKS5 and 823 for HTTP/HTTPS.
Get a credential pair, export it, and point one ProxyAgent at
gw.anonedge.com:823.
Start routing on a pay-as-you-go account and
take the host, ports and login from
the quickstart. Billing is per
gigabyte and credits do not expire —
check the per-GB rates.