Home/Blog/Node.js Fetch Proxy
Developer Guide

Node.js Fetch Proxy: Using undici ProxyAgent

Set HTTP_PROXY, run your script, and Node's built-in fetch() goes straight to the target anyway. That is by design. Here is how to route native fetch through a proxy the documented way: a dispatcher per request, setGlobalDispatcher for the whole process, or EnvHttpProxyAgent when you want env vars to work again.

7 min read·Node.js 18+·Last updated: July 2026

Quick Answer

Node's native fetch does not read HTTP_PROXY / HTTPS_PROXY by default. Install undici and pass a ProxyAgent as the dispatcher option on a single request, or register it once with setGlobalDispatcher() so every fetch() in the process exits through the proxy.

  • Per request: fetch(url, { dispatcher: new ProxyAgent(proxyUrl) })
  • Whole process: setGlobalDispatcher(new ProxyAgent(proxyUrl))
  • Env-var driven: setGlobalDispatcher(new EnvHttpProxyAgent()), or Node 24's opt-in NODE_USE_ENV_PROXY=1

If you came from the shell, this is a surprise: curl, wget, and most CLI tools honor proxy env vars out of the box, as we show in mobile proxy with cURL and wget. Node's fetch() does not. The examples below use the same mobileproxies.org placeholder credentials as that guide, so you can swap in your real slot and follow along.

Why native fetch ignores HTTP_PROXY

Node's built-in fetch() is implemented on top of undici, an HTTP/1.1 client written from scratch for Node.js. It shipped enabled by default in Node 18 and lost its experimental label in Node 21. You can check the bundled undici version with process.versions.undici.

Unlike curl, undici's fetch does not consult HTTP_PROXY, HTTPS_PROXY, or NO_PROXY unless you explicitly opt in. Proxy support is wired through the dispatcher abstraction instead: every fetch call is performed by a dispatcher, and the dispatcher request option is documented with a default of "the global dispatcher". Env-var behavior only exists as an opt-in: the NODE_USE_ENV_PROXY=1 environment variable (added in Node v24.0.0 and v22.21.0) and the equivalent --use-env-proxy flag (v24.5.0 and v22.21.0), both still marked Stability 1.1 (Active Development) in the Node docs.

dispatcher option

Route one fetch call through a proxy, leave the rest direct

setGlobalDispatcher

Route every fetch in the process, no call-site changes

EnvHttpProxyAgent

Bring back HTTP_PROXY / HTTPS_PROXY / NO_PROXY semantics

Per-request proxy with ProxyAgent

ProxyAgent is not exposed as a global, so install undici from npm even though a copy is bundled inside Node:

npm install undici

The constructor accepts a proxy URL string (or a URL object, or an options object). Pass the agent as the dispatcher option; Node's own globals documentation shows exactly this pattern for custom dispatchers on the built-in fetch:

import { ProxyAgent } from "undici"

const proxyAgent = new ProxyAgent(
  "http://u_4a9c:p_2X7q@proxy.mobileproxies.org:8000"
)

// Node's built-in fetch, routed through the mobile proxy
const res = await fetch("https://api.ipify.org", {
  dispatcher: proxyAgent,
})
console.log(await res.text())
// -> a carrier IP, not your machine's address

The per-request form is the right default when you mix traffic: scraping requests go through the proxy while health checks, telemetry, and internal API calls stay direct. Keep one ProxyAgent instance and reuse it across requests so undici can pool connections, rather than constructing a new agent per call.

Process-wide proxy with setGlobalDispatcher

When an existing codebase has fetch() calls scattered everywhere, changing each call site is not realistic. Register the agent once instead. The Node.js docs state that calling setGlobalDispatcher() from the installed undici package "will affect both undici and Node.js" - it registers the dispatcher on a global symbol that the built-in fetch reads.

import { setGlobalDispatcher, ProxyAgent } from "undici"

setGlobalDispatcher(
  new ProxyAgent("http://u_4a9c:p_2X7q@proxy.mobileproxies.org:8000")
)

// Every fetch() in the process now exits through the mobile proxy
const res = await fetch("https://api.ipify.org")
console.log(await res.text())

Run this in your entry point before anything else issues requests. The trade-off is bluntness: third-party libraries that use fetch will also go through the proxy, which is sometimes exactly what you want and sometimes a debugging session. A request-level dispatcher always overrides the global one, so you can still punch individual calls out to a direct connection.

Proxy authentication

Mobile proxy slots authenticate with username and password. The simplest form embeds them in the proxy URI, as above. The undici docs specify the precedence chain for the proxy-authorization header: the token option first, then the deprecated auth option, then credentials embedded in the URI (which are URL-decoded before being base64-encoded). The explicit form:

import { ProxyAgent } from "undici"

const proxyAgent = new ProxyAgent({
  uri: "http://proxy.mobileproxies.org:8000",
  token: `Basic ${Buffer.from("u_4a9c:p_2X7q").toString("base64")}`,
})

Two documented gotchas. First, auth is deprecated - use token. Second, do not put a proxy-authorization header in the fetch request headers: ProxyAgent throws an InvalidArgumentError if it finds one there, since credentials must go through the constructor. If your password contains special characters and you use the URI form, percent-encode them (@ becomes %40).

EnvHttpProxyAgent: env vars, restored

If you want the classic behavior where deployment config decides the proxy, undici ships a dispatcher for exactly that. EnvHttpProxyAgent (added in undici v6.14.0, now marked Stable in the undici docs) reads http_proxy, https_proxy, and no_proxy plus their uppercase variants, with lowercase taking precedence when both are set.

export HTTP_PROXY="http://u_4a9c:p_2X7q@proxy.mobileproxies.org:8000"
export HTTPS_PROXY="$HTTP_PROXY"
export NO_PROXY="localhost,127.0.0.1"
import { setGlobalDispatcher, EnvHttpProxyAgent } from "undici"

setGlobalDispatcher(new EnvHttpProxyAgent())

// fetch() now follows HTTP_PROXY / HTTPS_PROXY / NO_PROXY
const res = await fetch("https://api.ipify.org")

The constructor also accepts httpProxy, httpsProxy, and noProxy options that override the corresponding environment variables, which is handy in tests.

Zero-code alternative: on Node v24.0.0+ or v22.21.0+, start the process with NODE_USE_ENV_PROXY=1 node app.js (or node --use-env-proxy app.js on v24.5.0+) and Node parses the same three env vars at startup and tunnels requests through the proxy. Both switches are Stability 1.1, so pin your Node version if you rely on them in production.

Verify the egress IP and rotate it

Whichever wiring you chose, confirm it before pointing real traffic at it. Request api.ipify.org through the proxy and check that the address is a carrier IP. When you need a fresh IP, call the mobileproxies.org switch endpoint - directly, not through the proxy, so no dispatcher on that call:

// Rotate the slot to a new carrier IP (direct call, no dispatcher)
await fetch("https://buy.mobileproxies.org/api/v1/proxies/us-mob-01/switch", {
  method: "POST",
  headers: { Authorization: "Bearer YOUR_API_KEY" },
})

// Give the new IP a few seconds to bind, then re-check
await new Promise((r) => setTimeout(r, 4000))
const res = await fetch("https://api.ipify.org", { dispatcher: proxyAgent })
console.log(await res.text()) // -> a different carrier IP

This fetch-plus-rotation loop is the core of most Node data pipelines: crawlers built on native fetch for web scraping rotate between batches, and price monitoring jobs rotate per run so every check of a product page arrives from a clean consumer IP instead of a flagged datacenter range.

Pitfalls to watch

  • Env vars silently ignored. Setting HTTP_PROXY without EnvHttpProxyAgent or NODE_USE_ENV_PROXY=1 does nothing to fetch. No warning, no error - requests just go direct. Always verify the egress IP.
  • Mixing fetch implementations. The undici docs warn against mixing classes: use the global fetch() with the global Request/Response/Headers/FormData, and undici's fetch() with undici's classes. Passing one implementation's objects to the other can throw.
  • proxy-authorization in request headers. ProxyAgent rejects it with an InvalidArgumentError. Credentials belong in the constructor (token or the URI).
  • NO_PROXY surprises. With EnvHttpProxyAgent, a host matched by no_proxy bypasses the proxy. If a target unexpectedly shows your real IP, check that list first.
  • SOCKS5 is a different path. ProxyAgent speaks to HTTP proxies. mobileproxies.org slots expose both HTTP and SOCKS5 ports, so use the HTTP port from Node fetch; if your stack needs SOCKS5, see the Python requests SOCKS5 example for how the same slot is used over SOCKS.

Sources

Related Guides

Point fetch() at a real carrier IP

Grab a dedicated 4G/5G slot, drop the host and credentials into a ProxyAgent, and rotate on the API whenever you need a fresh IP.