Skip to content
Web scraping

Using proxies in Node.js: fetch, undici, axios and got

Node ignores the proxy environment variables entirely. Working configuration for every client, tested against a proxy that logs each request.

Node is the one major runtime where setting HTTPS_PROXY does nothing. There is no proxy support in the built-in HTTP clients, so a request you believe is proxied leaves from your own address with no warning and no error. This guide gives the working configuration for each client, and the two failures that catch people.

Every result below was tested against a local proxy that logs each request, so “it worked” means the proxy recorded the connection, not that the code ran without throwing.

#The environment variables do not apply

Verified on Node 24.2.0: a request made with HTTPS_PROXY set reached the destination directly. Nothing was logged by the proxy.

HTTPS_PROXY=http://127.0.0.1:8888 node -e "fetch('https://api.github.com/')"
# The proxy logged nothing. The request went direct.

This is a design decision rather than a bug. Node’s HTTP stack has never read the proxy environment, so tools that work everywhere else silently bypass your proxy here. If your deployment sets those variables and expects them to be honoured, the environment-variable guide explains what other runtimes do with them.

#Global fetch: use a global dispatcher

Node’s global fetch is built on undici, and undici routes through a dispatcher. Install undici, then replace the global one:

import { ProxyAgent, setGlobalDispatcher } from 'undici';

setGlobalDispatcher(new ProxyAgent('http://user:[email protected]:8000'));

const res = await fetch('https://api.github.com/');   // proxied

This is process-wide. Every fetch in the process now uses the proxy, including calls made by libraries you did not write. That is usually what you want for a scraper and rarely what you want inside a larger application.

#The per-call dispatcher trap

The obvious way to scope it to one request does not work on the global fetch:

// Fails on Node 24.2.0
await fetch(url, { dispatcher: new ProxyAgent(PROXY) });
// TypeError: fetch failed
//   cause: UND_ERR_INVALID_ARG

The error message says fetch failed, which reads like a network problem. It is not. The global fetch rejects the dispatcher option outright, and the request never reaches the proxy.

Importing fetch from undici instead accepts it, and the proxy logged the connection:

import { fetch, ProxyAgent } from 'undici';

const res = await fetch(url, { dispatcher: new ProxyAgent(PROXY) });   // works

The two functions have the same name and different options. If you want per-request proxy selection, import undici’s fetch explicitly rather than relying on the global.

#What each client needs

Client How to proxy it Scope
Global fetch setGlobalDispatcher(new ProxyAgent(url)) Whole process
undici fetch dispatcher option per call Per request
undici request dispatcher option per call Per request
axios httpsAgent plus proxy: false Per request or instance
axios, built-in proxy: { protocol, host, port } Per request or instance
got agent: { https: agent } Per request or instance
node:http / node:https agent option Per request

Tested versions: Node 24.2.0, undici 8.10.0, axios 1.20.0, got 15.1.0, https-proxy-agent 9.1.0. All five working rows were confirmed by the proxy log.

#axios: two ways, and one that surprises people

import axios from 'axios';
import { HttpsProxyAgent } from 'https-proxy-agent';

// Option 1: an agent. Set proxy:false or axios will try to do both.
const r1 = await axios.get(url, {
  httpsAgent: new HttpsProxyAgent('http://user:[email protected]:8000'),
  proxy: false,
});

// Option 2: axios's own proxy option
const r2 = await axios.get(url, {
  proxy: { protocol: 'http', host: 'gateway.example', port: 8000,
           auth: { username: 'user', password: 'pass' } },
});

Both were confirmed working. The proxy: false in the first is not optional decoration: without it axios applies its own proxy handling on top of the agent, and the two can conflict. Set one mechanism, not both.

#got

import got from 'got';
import { HttpsProxyAgent } from 'https-proxy-agent';

const res = await got(url, {
  agent: { https: new HttpsProxyAgent('http://user:[email protected]:8000') },
});

Note the shape: agent is an object keyed by protocol. Set https for https:// targets and http for plain ones. Setting only https and then requesting an http:// URL silently goes direct, which is the same class of failure as the missing environment variables.

#The 403 that is not a proxy problem

One test returned 403 through a working proxy. The proxy logged the connection, so the request was proxied correctly. The destination refused it.

// undici's low-level request sends no User-Agent at all
const r = await request(url, { dispatcher: new ProxyAgent(PROXY) });
// 403

const r2 = await request(url, {
  dispatcher: new ProxyAgent(PROXY),
  headers: { 'user-agent': 'my-tool/1.0' },
});
// 200

GitHub’s API requires a user agent and refuses requests without one. Node’s low-level clients send very few headers by default, so this class of failure appears as soon as you leave the browser-shaped clients behind.

The habit worth building: when a proxied request fails, check whether the proxy saw it before you change any proxy setting. A refusal that reached the destination is a destination problem, and no amount of proxy configuration will fix it.

#node:http and node:https

The built-in modules take an agent, which is what the higher-level libraries wrap. Confirmed working, with the proxy logging the connection:

import https from 'node:https';
import { HttpsProxyAgent } from 'https-proxy-agent';

https.get('https://api.github.com/', {
  agent: new HttpsProxyAgent('http://user:[email protected]:8000'),
  headers: { 'user-agent': 'my-tool/1.0' },
}, res => {
  console.log(res.statusCode);
  res.resume();
});

Use node:https for https:// targets and node:http for plain ones. The agent classes are not interchangeable, and mixing them fails in ways that look like network errors.

#Reuse the agent: a measured difference

Creating an agent per request is the most common performance mistake here, and the cost is easy to measure. Five identical requests were sent twice, once through a single shared ProxyAgent and once with a new agent for each request. The proxy counted the tunnels it was asked to open:

Approach Requests CONNECT tunnels opened
One shared ProxyAgent 5 1
A new ProxyAgent per request 5 5
// Do this: create once, reuse
const agent = new ProxyAgent(PROXY);
for (const url of urls) {
  const r = await request(url, { dispatcher: agent });
  r.body.dump();
}

Each extra tunnel is a full TLS handshake to the destination through the proxy. On a slow residential path that is the dominant cost of a small request, and it is entirely avoidable.

The exception is deliberate rotation. If you want a different exit per task, hold one agent per identity and reuse each of them, rather than creating a fresh agent for every request.

#Proving the proxy is used

Do not infer this from the absence of errors. Ask something to report the address, and compare against the same call with no proxy:

import { fetch, ProxyAgent } from 'undici';

const viaProxy = await (await fetch('https://api.ipify.org',
  { dispatcher: new ProxyAgent(PROXY) })).text();
const direct   = await (await fetch('https://api.ipify.org')).text();

console.log({ viaProxy, direct });   // identical means no proxy was applied

On a rotating endpoint this reports one exit node at one moment, not a property of the pool. Sample several times before concluding anything about a country.

#Reading the errors

Node reports proxy failures through error codes that name the layer that failed. Each row below was produced deliberately and observed:

Code Cause Where to look
ECONNREFUSED Nothing is listening on the proxy port The port, or whether the proxy is running
ENOTFOUND The proxy hostname does not resolve A typo in the gateway host, or your DNS
UND_ERR_CONNECT_TIMEOUT The proxy accepted nothing in time Often the wrong scheme: https:// against an http:// proxy
UND_ERR_INVALID_ARG An option the client does not accept A dispatcher passed to the global fetch
HTTP 407 The proxy rejected your credentials The username format; see proxy authentication

The third row is worth memorising. Writing https:// for a proxy that speaks plain HTTP produces a timeout rather than a protocol error, because the proxy is waiting for a request while your client waits for a TLS handshake. Neither side is wrong, and neither will speak first.

The distinction that matters across all of these: the first four are raised before the destination is contacted, so they are proxy or configuration faults. A 407 is the proxy refusing you. Anything with a status code from the destination, including a 403, means the tunnel worked.

#Practical notes

  • Reuse the agent. Creating a ProxyAgent per request discards connection reuse and adds a handshake to every call.
  • Set a timeout. None of these clients bounds the wait by default in a way you should rely on. An unbounded call on a hot path will eventually hang.
  • Keep credentials out of logs. The proxy URL contains the password, and it is easy to log the whole configuration object during debugging.
  • Failed requests still cost traffic. A 403 through a metered proxy is billable. See bandwidth billing.

#A note on SOCKS

Everything above uses an HTTP proxy. For a SOCKS5 endpoint the pattern is the same but the agent differs: socks-proxy-agent provides one that the built-in modules, axios and got all accept in the same agent position. undici takes a socket factory rather than an agent, so the integration is less direct there.

The hostname question applies here as it does everywhere else. Resolve the destination at the proxy rather than on your own machine, or you leak your queries to your local resolver and resolve from the wrong place. That distinction is the difference between socks5 and socks5h in other tools, and the equivalent option in Node is usually a flag on the agent. We have not tested the SOCKS path here, so verify it with the address comparison above rather than trusting the configuration to be correct.

#How this was tested

A local forward proxy on 127.0.0.1:8888 handled CONNECT and logged every request. Each client made one HTTPS request to the same destination. A configuration counts as working only where the proxy log recorded the connection; a call that succeeded without appearing in the log was treated as having gone direct. A control request through curl confirmed the proxy logged correctly before any Node test ran.

Tested on macOS on 29 August 2026, with the versions listed above. Client behaviour changes between releases, so re-run the address comparison on your own versions rather than assuming these results hold.

Frequently asked questions

Why does HTTPS_PROXY not work in Node?
Because Node's HTTP stack has never read the proxy environment variables. This was confirmed on Node 24.2.0: a request with HTTPS_PROXY set reached the destination directly and the proxy recorded nothing. You must configure a dispatcher or an agent explicitly.
What does "TypeError: fetch failed" with UND_ERR_INVALID_ARG mean?
You passed a dispatcher option to Node's global fetch, which does not accept it. Either call setGlobalDispatcher to set it process-wide, or import fetch from undici, which does accept a per-call dispatcher.
Should I use undici, axios or got?
All three work. Use undici if you are already on global fetch and want the fewest dependencies. Use axios or got if you want per-instance configuration and their retry and hook features. The proxy setup differs in shape, not in capability.
Why do I need proxy:false when I set httpsAgent in axios?
Because axios will otherwise apply its own proxy handling in addition to the agent, and the two mechanisms conflict. Choose one: either the agent with proxy set to false, or axios's built-in proxy object with no agent.
My proxied request returns 403. Is the proxy broken?
Probably not. A status code from the destination proves the tunnel worked. Check the proxy's log to confirm it saw the connection, then look at what you sent. Node's low-level clients send very few headers, and some APIs refuse requests with no User-Agent.
Does creating a new agent per request matter?
Measurably. Five requests through one shared ProxyAgent opened a single CONNECT tunnel, while creating a new agent for each opened five. Each extra tunnel is a full handshake through the proxy.

Sources

  1. undici: ProxyAgent and dispatcher documentation
  2. Node.js: the http and https modules and the agent option
  3. RFC 9110: HTTP Semantics, the CONNECT method and status 407

Read this page as Markdown · Quote it freely under CC BY 4.0 with a link back.