Integration · HTTP clients
How to use a proxy with Node.js fetch
Node's built-in fetch has no proxy option. It runs on undici, and undici's ProxyAgent passed as dispatcher is the documented way to send it through an authenticated proxy: fetch(url, { dispatcher: new ProxyAgent('http://user:pass@host:port') }). Recent Node versions can also read HTTPS_PROXY when NODE_USE_ENV_PROXY=1 is set.
Reviewed 30 Oct 2026
http://USERNAME _pool-arcfast _country-de:PASSWORD@ proxy.arcproxies.io:8000The short answer
ProxyAgenttakes the credentials from the proxy URL and sends them as Basic proxy authentication.- One agent per identity: an agent pools its connections, so reuse it for a sticky session and
close()it when done. - A rejected tunnel surfaces as
TypeError: fetch failed; the reason, "Proxy response (407) !== 200", sits in the error'scausechain. - undici's
ProxyAgentalso acceptssocks5://URLs.
Setup
Set up Node.js fetch with a proxy, step by step
- 01
Install undici
Importing
fetchandProxyAgentfrom the same package keeps them on the same undici version. undici 8 requires Node 22.19 or later.shell1 linenpm i undici - 02
Fetch through the gateway and check the exit
The agent is the dispatcher for this request. The echo service shows the exit the target sees.
check-exit.mjs10 linesimport { fetch, ProxyAgent } from 'undici';const dispatcher = new ProxyAgent('http://USERNAME_pool-arcfast_country-de:PASSWORD@proxy.arcproxies.io:8000');const res = await fetch('https://api.ipify.org?format=json', { dispatcher, signal: AbortSignal.timeout(30_000),});console.log(await res.json()); // { ip: '…' }await dispatcher.close(); - 03
One agent per sticky session
Create an agent with a fresh
_session-handle per task and reuse it for every request in that task. Closing it afterwards releases its pooled connections.sticky.mjs21 linesimport { fetch, ProxyAgent } from 'undici';import { randomBytes } from 'node:crypto';async function withSession(task, minutes = 10) { const handle = randomBytes(4).toString('hex'); const dispatcher = new ProxyAgent( `http://USERNAME_pool-arcfast_country-de_session-${handle}_sesstime-${minutes}:PASSWORD@proxy.arcproxies.io:8000`, ); try { return await task((url) => fetch(url, { dispatcher, signal: AbortSignal.timeout(30_000) })); } finally { await dispatcher.close(); }}const statuses = await withSession(async (get) => { const out = []; for (let page = 1; page <= 3; page++) out.push((await get(`https://example.com/search?page=${page}`)).status); return out;});console.log(statuses); - 04
Geo-target with a different agent
The country and city are part of the username, so each market is its own agent. Cities come from the published list, lowercased without spaces.
geo.mjs13 linesimport { fetch, ProxyAgent } from 'undici';const agents = { 'fr-lyon': new ProxyAgent('http://USERNAME_pool-arcfast_country-fr_city-lyon:PASSWORD@proxy.arcproxies.io:8000'), 'ca-toronto': new ProxyAgent('http://USERNAME_pool-arcfast_country-ca_city-toronto:PASSWORD@proxy.arcproxies.io:8000'), 'de': new ProxyAgent('http://USERNAME_pool-arcfast_country-de:PASSWORD@proxy.arcproxies.io:8000'),};for (const [market, dispatcher] of Object.entries(agents)) { const res = await fetch('https://example.com/pricing', { dispatcher }); console.log(market, res.status);}await Promise.all(Object.values(agents).map((a) => a.close())); - 05
Read the cause chain on failures
fetchrejects with a genericTypeError: fetch failed. Walkerr.causeto find the proxy's answer: a 407 means stop, other failures and timeouts are worth a retry on a new handle.fetch.mjs25 linesimport { fetch, ProxyAgent } from 'undici';import { randomBytes } from 'node:crypto';import { setTimeout as sleep } from 'node:timers/promises';const causes = (err) => { const out = []; for (let e = err; e; e = e.cause) out.push(String(e.message)); return out.join(' | '); };async function fetchWithRetry(url, attempts = 4) { for (let attempt = 0; attempt < attempts; attempt++) { const dispatcher = new ProxyAgent( `http://USERNAME_pool-arcfast_country-de_session-${randomBytes(4).toString('hex')}_sesstime-5:PASSWORD@proxy.arcproxies.io:8000`, ); try { const res = await fetch(url, { dispatcher, signal: AbortSignal.timeout(30_000) }); if (res.status !== 429 && res.status < 500) return await res.text(); } catch (err) { const why = causes(err); if (why.includes('(407)')) throw new Error('proxy rejected the credentials'); console.warn(`attempt ${attempt + 1}: ${why}`); } finally { await dispatcher.close(); } await sleep(1000 * 2 ** attempt); } throw new Error(`gave up on ${url}`);} - 06
Or: proxy from the environment, no code changes
On Node 24 and 22.21 or later,
NODE_USE_ENV_PROXY=1makes Node readHTTP_PROXY,HTTPS_PROXYandNO_PROXYat startup. It is marked as active development, and it gives you one proxy for the whole process, so per-task sessions still needProxyAgent.shell3 linesNODE_USE_ENV_PROXY=1 \HTTPS_PROXY="http://USERNAME_pool-arcfast_country-de:PASSWORD@proxy.arcproxies.io:8000" \node app.mjs
How undici's ProxyAgent talks to the gateway
- HTTPS targets go through a CONNECT tunnel; TLS runs between Node and the site.
- Credentials come from the URL's userinfo, URL-decoded, unless you pass
token(a ready-madeProxy-Authorizationvalue). The olderauthoption is deprecated in favour oftoken. - SOCKS5: a
socks5://URI makesProxyAgentdelegate to undici'sSocks5ProxyAgent, against the same gateway port with the same credentials. - Plain HTTP targets are forwarded in absolute form by default;
proxyTunnel: truetunnels them too.
Rotating vs sticky with fetch
A rotating username lets each new connection take a new exit, but an agent keeps connections alive, so requests through one agent to one host can share an IP. That is why the examples tie one agent to one _session- handle: the agent's connection pool and the exit IP then mean the same thing. Rotating vs sticky sessions explains when to use which.
Using the node-fetch package instead
The node-fetch npm package predates built-in fetch and does not use undici. It takes an agent option, so the proxy comes from https-proxy-agent: fetch(url, { agent: new HttpsProxyAgent('http://user:pass@host:port') }). On current Node, the built-in fetch with ProxyAgent needs one dependency fewer. Axios is the other common choice.
The problem
Common pitfalls
Passing an agent instead of a dispatcher
Built-in fetch ignores agent. It takes dispatcher, and only undici dispatchers.
Creating an agent per request in a hot loop
Each agent opens its own connections. Reuse an agent for a session; create a new one only when you want a new identity, and close() the old one.
Logging err.message only
"fetch failed" says nothing. The proxy's status and undici's error code live in err.cause.
No timeout
Pass signal: AbortSignal.timeout(ms); otherwise a stalled exit holds the request open for undici's much longer defaults.
FAQ
Questions
Something not covered? Write to contact@arcproxies.io.
How do I use a proxy with fetch in Node.js?
Install undici and pass a ProxyAgent as the dispatcher: fetch(url, { dispatcher: new ProxyAgent('http://user:pass@host:port') }). Node's built-in fetch has no proxy option of its own.
Does Node.js fetch respect HTTPS_PROXY?
Only when enabled: set NODE_USE_ENV_PROXY=1 or start Node with --use-env-proxy (Node 24 and 22.21 or later). The feature is marked as active development in the Node docs.
Why does fetch say fetch failed when the proxy rejects me?
Built-in fetch wraps network errors in TypeError: fetch failed. The underlying reason, for example "Proxy response (407) !== 200 when HTTP Tunneling", is in err.cause.
Can undici use a SOCKS5 proxy?
Yes. new ProxyAgent('socks5://user:pass@host:port') delegates to undici's Socks5ProxyAgent. It is documented in undici's ProxyAgent reference.
Sources & review
Last reviewed by the ARC Proxies team. Prices are read from the live catalog at build time.
- [1]undici: ProxyAgent
- [2]Node.js: HTTP: Built-in proxy support
- [3]Node.js: CLI: NODE_USE_ENV_PROXY=1
Next
What people build with it
Integrations
Other http clients
Get started
Get credentials and paste them in.
Sign up, buy a gigabyte, generate a username in the dashboard. Code WELCOME15 takes 15% off your first purchase; bandwidth never expires.