Node.js fetch proxy agent: the undici dispatcher

    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.

    Node's global fetch ignores HTTP_PROXY, and there is no flag

    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.

    Setting up a Node.js fetch proxy agent with undici

    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.

    The version-skew trap, and the rule that avoids it

    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:

    • Use setGlobalDispatcher when you want process-wide proxying, or
    • import fetch 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.causefetch 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.

    Authentication, and which form ends up in your logs

    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.

    Where to keep the secret

    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.

    HTTPS targets through an HTTP proxy

    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.

    Per-request dispatchers for rotation, and the pooling trap

    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.

    EnvHttpProxyAgent, and what NO_PROXY actually does

    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.

    The built-in path: NODE_USE_ENV_PROXY, and its version boundary

    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

    SOCKS5 with fetch

    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.

    Decoding the errors

    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.

    The same request in Axios

    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.

    Frequently asked questions

    Does Node.js fetch support HTTP_PROXY?

    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.

    Why does fetch with a dispatcher option throw "fetch failed"?

    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.

    How do I send proxy credentials with undici ProxyAgent?

    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.

    Why is my exit IP not changing between requests?

    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.

    Can I use SOCKS5 with Node's fetch?

    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.

    Start routing

    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.