Node 18 added a global fetch and quietly took away the easy way to proxy it. The fetch you now get for free comes from undici, it has no proxy option, and unlike most tools it does not read HTTP_PROXY from the environment, so the first proxied request goes out on your real IP and nothing warns you. This guide covers proxies with native Node fetch end to end: undici's ProxyAgent, setting it globally versus per request, authentication, why SOCKS needs a different dispatcher, rotating a pool, and verifying the exit IP.
We run a proxy network and a live proxy checker, so the Node fetch proxy question we field most is simply "where does the proxy go", because there is no option for it on the call. The answer is a dispatcher, shown below. Every example runs on Node 18 or newer with undici installed (npm install undici). Where one needs a live proxy, pull a fresh one from our free proxy API, which returns recently checked endpoints with no key.
Why native fetch ignores your proxy
The fetch global is undici under the hood, and undici routes requests through an object it calls a Dispatcher. The default dispatcher opens direct connections and knows nothing about proxies or environment variables. There is no fetch(url, { proxy }), so setting one there does nothing. To proxy fetch, you replace or override that dispatcher with a ProxyAgent.
fetch(url)
no proxy option exists
Default dispatcher
connects direct, ignores env
ProxyAgent
from undici
setGlobalDispatcher or dispatcher:
now fetch proxies
Set it globally
The simplest approach: build a ProxyAgent and install it as the global dispatcher. Every fetch after that uses it.
import { ProxyAgent, setGlobalDispatcher } from "undici";
setGlobalDispatcher(new ProxyAgent("http://203.0.113.7:8080"));
const res = await fetch("https://httpbin.org/ip");
console.log(await res.json());
This is right when the whole process should go through one proxy. It is a poor fit for rotation, because it changes the exit for everything at once.
Set it per request
Native fetch in Node accepts a dispatcher option (an undici extension), so you can proxy a single call without touching the global default:
import { ProxyAgent } from "undici";
const agent = new ProxyAgent("http://203.0.113.7:8080");
const res = await fetch("https://example.com", { dispatcher: agent });
The per-call dispatcher is the primitive rotation is built on: different agent, different exit, no effect on other requests.
Authentication
Put the credentials in the proxy URL, user:pass@host:port:
const agent = new ProxyAgent("http://user:pass@203.0.113.7:8080");
Or pass a token, which undici sends as the Proxy-Authorization header, handy when the password is awkward in a URL:
const token = "Basic " + Buffer.from("myuser:mypass").toString("base64");
const agent = new ProxyAgent({ uri: "http://203.0.113.7:8080", token });
A wrong or missing login returns HTTP 407 Proxy Authentication Required, which confirms the proxy is alive and only the credentials are off.
SOCKS needs a different dispatcher
ProxyAgent handles HTTP and HTTPS proxies, tunnelling HTTPS through CONNECT. It does not speak SOCKS. For a SOCKS proxy you need a SOCKS-capable dispatcher, built with the socks package and a custom undici connector, or you step across to a client like axios with socks-proxy-agent, which we cover in the axios guide. For most scraping an HTTP proxy is all you need and avoids the extra plumbing.
Rotating a pool
Because the dispatcher is per request, rotation is choosing which ProxyAgent to attach and retrying on another when one fails:
import { ProxyAgent } from "undici";
const POOL = [
"http://user:pass@203.0.113.7:8080",
"http://user:pass@203.0.113.24:3128",
"http://user:pass@198.51.100.14:8080",
];
const agents = POOL.map((p) => new ProxyAgent(p));
async function getRotating(url, tries = 4) {
for (let i = 0; i < tries; i++) {
const agent = agents[Math.floor(Math.random() * agents.length)];
try {
const res = await fetch(url, {
dispatcher: agent,
signal: AbortSignal.timeout(15000), // a dead exit fails in 15s
});
if (res.ok) return await res.text();
} catch {
// dead or blocked exit, try the next
}
}
throw new Error(`all ${tries} proxies failed for ${url}`);
}
Reusing the ProxyAgent objects keeps undici's connection pool per exit, and AbortSignal.timeout stops a silent proxy from hanging the call. Fill POOL from our free proxy API, which returns a plain-text list you can split at startup.
Verify the exit IP
Never assume the dispatcher took effect. Read an IP echo through the proxied call and compare it to your own address:
const real = await (await fetch("https://httpbin.org/ip")).json();
const via = await (await fetch("https://httpbin.org/ip", {
dispatcher: new ProxyAgent("http://203.0.113.7:8080"),
})).json();
console.log("real:", real.origin, "proxy:", via.origin);
if (real.origin === via.origin) throw new Error("proxy is not changing your IP");
If the two match, no dispatcher was attached to that call, or setGlobalDispatcher was never run. For a fuller read of the exit, our proxy checker reports the exit IP, country, latency and anonymity grade in one paste.
Where to go from here
Native fetch is a fine client once you know the proxy lives on a dispatcher, not an option, and that nothing reads your environment for you. After that the discipline is the same as any proxied client: verify each exit, bound your timeouts, and rotate a pool that is alive.
The Node.js proxy guide covers the older http/https module paths, the axios guide is the route to SOCKS and a more feature-rich client, and proxies for web scraping covers choosing the right proxy type. When a hand-rolled pool of dispatchers becomes a chore, a rotating gateway that returns a fresh residential IP per request at $0.44/GB lets one dispatcher keep a single endpoint while the exit changes underneath it.