Node.js Proxy Setup: fetch, undici, axios and got
Undici's ProxyAgent, agent overrides for axios and got, keep-alive tunnels, and which Node clients read HTTP_PROXY from the environment.
Each Node HTTP client configures a proxy differently, and some read HTTP_PROXY from the environment while others never do. Find your client below and copy its configuration. Every example reaches an https:// target through an http:// proxy URL, which is what a Node4 endpoint is: the hop to the proxy is plain HTTP that upgrades into a tunnel with CONNECT.
Browser automation is separate: Chromium handles proxy credentials unlike any HTTP client, and Puppeteer and Playwright proxies covers it. The Python equivalent of this page is Python proxy integration.
Native fetch and undici
Node's built-in fetch (Node 18 and later) is undici underneath, and undici's ProxyAgent is the supported way to route it:
import { ProxyAgent, setGlobalDispatcher } from "undici";
const proxyAgent = new ProxyAgent({
uri: "http://HOST:PORT",
token: "Basic " + Buffer.from("USERNAME:PASSWORD").toString("base64"),
});
setGlobalDispatcher(proxyAgent);
const res = await fetch("https://api.ipify.org");
console.log(await res.text());setGlobalDispatcher routes every subsequent fetch in the process through the proxy. To proxy only some requests, skip the global and pass a dispatcher per call:
const res = await fetch("https://api.ipify.org", { dispatcher: proxyAgent });The dispatcher option is undici's extension, not part of the WHATWG fetch standard, so TypeScript's DOM typings do not know it. Import fetch from undici directly, or widen the type.
Credentials can ride in the uri like any proxy URL, but the token option takes the header value directly, which sidesteps URL-encoding problems with awkward password characters. A plain fetch with no dispatcher does not read HTTP_PROXY; undici's environment pickup is opt-in through EnvHttpProxyAgent.
axios
axios's built-in proxy option has changed behavior across major releases, and for an https:// target through an http:// proxy it has a long record of sending the wrong kind of request instead of opening a tunnel. Configure an explicit agent pair instead:
import axios from "axios";
import { HttpProxyAgent } from "http-proxy-agent";
import { HttpsProxyAgent } from "https-proxy-agent";
const proxyUrl = "http://USERNAME:PASSWORD@HOST:PORT";
const client = axios.create({
proxy: false,
httpAgent: new HttpProxyAgent(proxyUrl),
httpsAgent: new HttpsProxyAgent(proxyUrl),
timeout: 30_000,
});
const { data } = await client.get("https://api.ipify.org");
console.log(data);proxy: false does two jobs: it disables the built-in path, and it stops axios reading HTTP_PROXY and HTTPS_PROXY from the environment, which it otherwise does by default. Supply both httpAgent and httpsAgent, so a plain http:// URL does not slip out unproxied. SOCKS5 needs a different agent again, covered in SOCKS5 proxies with axios.
got
got ships no built-in proxy support and no environment-variable pickup; its maintainers consider proxying the agent layer's job. Configuration is explicit and stable across versions:
import got from "got";
import { HttpProxyAgent } from "http-proxy-agent";
import { HttpsProxyAgent } from "https-proxy-agent";
const proxyUrl = "http://USERNAME:PASSWORD@HOST:PORT";
const body = await got("https://api.ipify.org", {
agent: {
http: new HttpProxyAgent(proxyUrl),
https: new HttpsProxyAgent(proxyUrl),
},
timeout: { request: 30_000 },
retry: { limit: 2 },
}).text();
console.log(body);got's retry engine helps behind a proxy: network errors and retriable status codes are retried with growing delays, restricted to idempotent methods by default. Cap it with retry.limit and leave the method restrictions alone unless you have thought about replaying writes.
https-proxy-agent and the core modules
http-proxy-agent and https-proxy-agent (plus socks-proxy-agent for SOCKS5, see SOCKS5 proxy setup) are named for the target, not the proxy. https-proxy-agent reaches https:// destinations, and it still expects a proxy URL beginning with http://. With the core modules:
import https from "node:https";
import { HttpsProxyAgent } from "https-proxy-agent";
const agent = new HttpsProxyAgent("http://USERNAME:PASSWORD@HOST:PORT");
https.get("https://api.ipify.org", { agent }, (res) => {
let data = "";
res.on("data", (chunk) => (data += chunk));
res.on("end", () => console.log(data));
});An https:// proxy URL makes the agent start a TLS handshake with the proxy listener, which answers in plaintext, and the connection dies with ERR_SSL_WRONG_VERSION_NUMBER before any request exists.
Keep-alive: one agent, shared
Construct agents once, at module scope. Every fresh socket through a proxy pays the TCP connection to the proxy, the CONNECT round trip and the TLS handshake with the target; an agent exists to pay those once and reuse the tunnel. The agents above accept the standard http.Agent options:
const agent = new HttpsProxyAgent("http://USERNAME:PASSWORD@HOST:PORT", {
keepAlive: true,
maxSockets: 20,
});new HttpsProxyAgent(...) inside a request handler leaks sockets under load and shows up as intermittent ECONNRESET and timeouts. Undici's ProxyAgent pools per origin without asking, but the same rule applies: one instance, shared.
On a rotating product, the exit is chosen when the proxied connection opens. A kept-alive tunnel keeps its exit for as long as it lives, so for a fresh address on every request you need a fresh connection per request; to hold an exit deliberately for longer, use a session id. Both are covered in sticky vs rotating sessions, and the per-connection rule itself in IP rotation strategies.
Who reads HTTP_PROXY
HTTP_PROXY, HTTPS_PROXY and NO_PROXY are a convention, not a standard:
| Client | Reads the environment? |
|---|---|
Native fetch | No |
| undici | Opt-in, with EnvHttpProxyAgent as the dispatcher |
| axios | Yes, by default, unless proxy: false |
| got | No, by stated policy |
The deprecated request package | Yes, which is where many older assumptions come from |
Recent Node releases have added runtime-level environment support behind an experimental flag (NODE_USE_ENV_PROXY); check the release notes for the version you run rather than assuming it is on. The dependable pattern is to make the environment explicit at the edge of your program: read one variable yourself, fail loudly if it is missing, and build one agent from it.
const proxyUrl = process.env.NODE4_PROXY_URL;
if (!proxyUrl) throw new Error("NODE4_PROXY_URL is not set");A boot-time throw is better than the alternative, which is every request quietly leaving with your server's own address.
TLS through the CONNECT tunnel
After the CONNECT completes, your process performs the TLS handshake with the target through the tunnel. Certificate validation happens in your Node process against your CA store. The proxy learns the hostname from the CONNECT line and nothing inside the encrypted stream.
So UNABLE_TO_VERIFY_LEAF_SIGNATURE and its relatives mean the target's certificate chain did not validate: a misconfigured target, a missing intermediate, or a corporate middlebox on your side re-signing traffic. Setting NODE_TLS_REJECT_UNAUTHORIZED=0 disables certificate verification for the whole process; do not ship it. A 407 is an authentication failure at our edge, covered in the 407 guide.
Verify the egress address
Finish every integration by making the same request direct and proxied, and comparing:
import { Agent, ProxyAgent } from "undici";
const proxyAgent = new ProxyAgent("http://USERNAME:PASSWORD@HOST:PORT");
const directAgent = new Agent();
const [direct, proxied] = await Promise.all([
fetch("https://api.ipify.org", { dispatcher: directAgent }).then((r) => r.text()),
fetch("https://api.ipify.org", { dispatcher: proxyAgent }).then((r) => r.text()),
]);
console.log({ direct, proxied });
if (direct === proxied) throw new Error("traffic is not leaving through the proxy");Wire it into startup or CI. It catches an environment variable that stopped arriving, an agent pair where only httpsAgent was set, and a dependency upgrade that changed proxy behavior. A static datacenter proxy should echo the same address every time; consecutive requests through a rotating gateway legitimately differ. If you are still choosing between static addresses you manage and a gateway that manages them for you, the datacenter proxies page and the pricing page lay out the options.