In Python, setting a proxy is a single proxies parameter in most libraries. Node.js is messier: built-in fetch, Axios and node-fetch take a proxy through three different mechanisms, SOCKS5 needs a separate package, and when you give a library the wrong option you often don't even get an error; the request quietly goes out without the proxy. That is why it matters to know which library reads which option.
In this article we cover the three basic ways to set a proxy in Node.js (environment variable, agent and dispatcher), using an authenticated proxy with built-in fetch, Axios and node-fetch, SOCKS5 proxies, and IP rotation with retries, all with working code. We ran the examples on Node.js 24 against a local test proxy that requires a username and password, and we noted the version mismatch we hit and how authentication errors look different from one library to another.
Ways to set a proxy in Node.js
Node.js has three mechanisms for sending an HTTP request through a proxy. Which library you use decides which mechanism applies.
- Environment variable. The
HTTP_PROXY,HTTPS_PROXYandNO_PROXYvariables. They route a whole script through the proxy without code changes, but not every library reads them. - Agent. Node.js's classic
httpandhttpsmodules open connections through anAgentobject. Axios and node-fetch use these modules, so when you give them an agent that connects to a proxy (such asHttpsProxyAgent), requests go through the proxy. - Dispatcher. Node.js's built-in
fetchdoes not use the classichttpmodule; it uses a client called undici. In undici, the object that manages connections is called adispatcher, and for a proxy you give it aProxyAgent.
This distinction is the most important point in the article: built-in fetch does not recognise the agent option. If you write fetch(url, { agent }) out of Axios habit, you get no error and the request goes out without the proxy.
We explain how a proxy works in general and the CONNECT tunnel set up for HTTPS requests in What Is a Proxy Server and How Does It Work?. Translating cURL commands into fetch and Axios (headers, body, form data) is the topic of our cURL in JavaScript article; this article focuses only on the proxy side.
How do you use a proxy with built-in fetch?
undici ProxyAgent
Install the undici package:
npm install undiciThen import fetch and ProxyAgent from the same package:
import { fetch, ProxyAgent } from "undici";
const dispatcher = new ProxyAgent("http://user:pass@pr.proxynet.io:8000");
const res = await fetch("https://httpbin.org/ip", {
dispatcher,
signal: AbortSignal.timeout(20_000),
});
console.log(res.status, await res.json());Even though the proxy address starts with http://, you can safely reach HTTPS sites: ProxyAgent opens a CONNECT tunnel with the proxy, and the TLS connection with the target happens inside that tunnel.
The version mismatch trap. Node.js ships its own copy of undici; the undici you install from npm may be newer. When we passed the npm ProxyAgent to Node.js's built-in fetch (using the global fetch without importing it), the request failed with a fetch failed error on Node.js 24.11 with undici 8.10; the cause was invalid onRequestStart method. Using the same agent with undici's own fetch worked fine. The rule is simple: take fetch from the same package you took ProxyAgent from.
To make every undici fetch call in the application use the same proxy, you can set a global dispatcher:
import { fetch, ProxyAgent, setGlobalDispatcher } from "undici";
setGlobalDispatcher(new ProxyAgent("http://user:pass@pr.proxynet.io:8000"));
const res = await fetch("https://httpbin.org/ip"); // no need to pass a dispatcherThe NODE_USE_ENV_PROXY environment variable
In current Node.js versions, built-in fetch reads the standard proxy environment variables when NODE_USE_ENV_PROXY is enabled. According to Node.js's enterprise network configuration guide, the feature is available from versions 22.21.0 and 24.5.0; the same behaviour can be turned on with the --use-env-proxy command-line option.
On Linux and macOS:
NODE_USE_ENV_PROXY=1 HTTPS_PROXY="http://user:pass@pr.proxynet.io:8000" node app.mjsIn Windows PowerShell:
$env:NODE_USE_ENV_PROXY = "1"
$env:HTTPS_PROXY = "http://user:pass@pr.proxynet.io:8000"
node app.mjsThis method needs no code changes; fetch("https://httpbin.org/ip") goes straight through the proxy. In our test, when the same command was run without NODE_USE_ENV_PROXY, the request never reached the proxy even though HTTPS_PROXY was set. You can keep internal network addresses out of the proxy with the NO_PROXY variable.
If you want a dispatcher that reads environment variables from code, undici's EnvHttpProxyAgent class does the same job:
import { fetch, EnvHttpProxyAgent } from "undici";
const res = await fetch("https://httpbin.org/ip", { dispatcher: new EnvHttpProxyAgent() });How do you use a proxy with Axios?
Axios uses the classic http and https modules in Node.js and offers two ways to use a proxy.
The built-in proxy option
The proxy option in Axios's request config works for unencrypted http:// addresses:
import axios from "axios";
const { data } = await axios.get("http://httpbin.org/ip", {
proxy: {
protocol: "http",
host: "pr.proxynet.io",
port: 8000,
auth: { username: "user", password: "pass" },
},
timeout: 20_000,
});
console.log(data);The password in the auth field is not encoded; you can write special characters as they are.
https-proxy-agent for HTTPS addresses
When the target address is HTTPS, letting an agent set up the tunnel gives more reliable results:
npm install axios https-proxy-agentimport axios from "axios";
import { HttpsProxyAgent } from "https-proxy-agent";
const httpsAgent = new HttpsProxyAgent("http://user:pass@pr.proxynet.io:8000");
const client = axios.create({
httpsAgent,
proxy: false, // turns off Axios's own proxy logic and leaves the tunnel to the agent
timeout: 20_000,
});
const { status, data } = await client.get("https://httpbin.org/ip");
console.log(status, data);Don't skip the proxy: false line. When no proxy option is given, Axios may try to apply the proxy from environment variables with its own logic, which means two conflicting proxy behaviours alongside the agent. Binding the agent once with axios.create saves you from repeating it on every call.
How do you use a proxy with node-fetch?
Before built-in fetch arrived, node-fetch was the most common fetch implementation in Node.js, and it still shows up often in older projects. Unlike built-in fetch, it uses the classic http module, so the proxy is passed with the agent option:
npm install node-fetch https-proxy-agentimport fetch from "node-fetch";
import { HttpsProxyAgent } from "https-proxy-agent";
const agent = new HttpsProxyAgent("http://user:pass@pr.proxynet.io:8000");
const res = await fetch("https://httpbin.org/ip", { agent });
console.log(res.status, await res.json());Version 3 of node-fetch is published only as an ES module; if you cannot load it with import in a CommonJS project that uses require, moving to built-in fetch is usually less work. There is no reason to add node-fetch to a new project.
How do you use a SOCKS5 proxy?
undici's ProxyAgent is for HTTP proxies; built-in fetch has no SOCKS5 support. To work with a SOCKS5 proxy, use the socks-proxy-agent package with Axios or node-fetch:
npm install socks-proxy-agentimport axios from "axios";
import { SocksProxyAgent } from "socks-proxy-agent";
// socks5h: the domain name is resolved on the proxy side, so no DNS query leaves your network
const agent = new SocksProxyAgent("socks5h://user:pass@pr.proxynet.io:1080");
const { data } = await axios.get("https://httpbin.org/ip", {
httpAgent: agent,
httpsAgent: agent,
proxy: false,
});
console.log(data);In node-fetch the same agent is passed as fetch(url, { agent }). In our test proxy's logs, requests sent with the socks5h:// scheme reached the proxy as a domain name, not an IP address. The socks5:// scheme resolves the domain name on your computer; the problems that causes are explained in WebRTC and DNS Leaks. Before choosing between SOCKS5 and an HTTP proxy, see SOCKS vs HTTP Proxy.
How do libraries take a proxy?
| Client | How to pass the proxy | Environment variable | SOCKS5 | Authentication error |
|---|---|---|---|---|
| Built-in fetch | NODE_USE_ENV_PROXY or --use-env-proxy | Only with the flag | None | fetch failed |
undici fetch | dispatcher: new ProxyAgent(...) | With EnvHttpProxyAgent | None | fetch failed, cause: request cancelled |
| Axios | proxy option or httpsAgent + proxy: false | If no option is given | With socks-proxy-agent | Error with status code 407 |
| node-fetch | agent | None | With socks-proxy-agent | Depends on the agent used |
http.request | agent | None | With socks-proxy-agent | Depends on the agent used |
The last column matters when debugging. In our test with a wrong password, Axios showed the cause clearly with the message Request failed with status code 407 and error.response.status === 407. undici's fetch only gave a fetch failed error and reported the cause as "Request was cancelled"; the message itself does not mention authentication. If you see fetch failed with undici, the first thing to check is the proxy username and password. We collect all causes of 407 in Proxy Authentication: User:Pass vs IP Whitelist.
Credentials and special characters
In every method that takes the proxy address as a URL (undici ProxyAgent, https-proxy-agent, socks-proxy-agent, environment variables), characters such as @, :, / and # in the password must be encoded. Otherwise the address is parsed incorrectly.
const user = process.env.PROXY_USER;
const pass = encodeURIComponent(process.env.PROXY_PASS);
const proxyUrl = `http://${user}:${pass}@pr.proxynet.io:8000`;Reading the credentials from environment variables instead of writing them into code keeps the password from showing up wherever the code is shared. In Axios's proxy.auth field, the password is written unencoded.
IP rotation and retries
In a real data collection job, requests fail from time to time: the proxy's exit point cannot reach the target, the target returns 429 or 503, or the connection times out. The example below picks randomly among several proxies, retries a failed request with exponential backoff and limits the number of requests running at once with small batches:
import { fetch, ProxyAgent } from "undici";
const PROXIES = [
"http://user:pass@pr.proxynet.io:8000",
"http://user:pass@pr.proxynet.io:8001",
];
const agents = PROXIES.map((p) => new ProxyAgent(p));
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function getWithRetry(url, { attempts = 4, baseMs = 500 } = {}) {
let lastError;
for (let i = 0; i < attempts; i++) {
const dispatcher = agents[Math.floor(Math.random() * agents.length)];
try {
const res = await fetch(url, { dispatcher, signal: AbortSignal.timeout(20_000) });
if (res.status === 429 || res.status >= 500) {
await res.body?.cancel();
throw new Error(`HTTP ${res.status}`);
}
return await res.text();
} catch (err) {
lastError = err;
await sleep(baseMs * 2 ** i + Math.random() * 250);
}
}
throw lastError;
}
async function crawl(urls, concurrency = 5) {
const results = [];
for (let i = 0; i < urls.length; i += concurrency) {
const batch = urls.slice(i, i + concurrency);
results.push(...(await Promise.allSettled(batch.map((u) => getWithRetry(u)))));
}
return results;
}
const urls = ["https://httpbin.org/ip", "https://httpbin.org/status/503", "https://example.com/"];
const results = await crawl(urls, 2);
results.forEach((r, i) =>
console.log(urls[i], r.status, r.status === "fulfilled" ? `${r.value.length} bytes` : r.reason.message),
);Three details in the code matter:
Promise.allSettledkeeps one failed request in a batch from stopping the others. WithPromise.all, a single503would lose the whole batch's results. In the example, the/status/503address was markedrejectedafter four attempts, while the other addresses returned their results.res.body?.cancel()releases the connection without reading the body of a response we are going to retry. Unread bodies can fill up the connection pool.- The random component (
Math.random() * 250) keeps requests that failed at the same moment from being retried at the same moment and causing a new pile-up.
Instead of managing the proxy list yourself, if you use a Rotating Proxy that gives a different exit IP on every connection through a single address, the PROXIES array shrinks to one element and rotation happens on the provider side. For jobs where the same IP must be kept for a session (logged-in pages, multi-step flows), a Sticky Proxy is preferred.
This example does not read the Retry-After header on a 429 response. An approach that honours the header and separates which status codes should not be retried is explained in HTTP Status Codes in Web Scraping. How the concurrency value actually affects speed is covered in Concurrency vs Parallelism.
How do you verify that the proxy works?
Check with every new setup that the request really goes through the proxy. The easiest way is to send a request to an address that returns the exit IP, first without the proxy and then with it:
import { fetch, ProxyAgent } from "undici";
const ip = async (options = {}) => (await (await fetch("https://api.ipify.org?format=json", options)).json()).ip;
console.log("without proxy:", await ip());
console.log("with proxy:", await ip({ dispatcher: new ProxyAgent(process.env.HTTPS_PROXY) }));If both lines show the same address, the request is not going through the proxy. The most common cause is passing agent to built-in fetch, or using an agent in Axios without writing proxy: false.
Common mistakes
- Passing
agentto built-in fetch. The option is silently ignored. The right key for fetch isdispatcher. - Passing npm undici's
ProxyAgentto global fetch. When versions don't match you get afetch failederror; importfetchfrom undici too. - Setting
HTTPS_PROXYand assuming built-in fetch will read it. It won't withoutNODE_USE_ENV_PROXYor--use-env-proxy. - Not writing
proxy: falsewith an agent in Axios. Two proxy mechanisms conflict. - Not encoding special characters in the password. The address is parsed wrongly and authentication fails.
- Resolving DNS locally with
socks5://. Usesocks5h://for remote resolution. - Not setting a timeout. A proxy exit that doesn't respond keeps a request without a timeout waiting for minutes. Use
AbortSignal.timeoutin undici andtimeoutin Axios. - Starting every request at once with
Promise.all. It strains both your own connection pool and the target site's rate limit.
Which method should you choose?
| Your situation | Recommendation |
|---|---|
| New project, few dependencies | undici fetch + ProxyAgent |
| Route an existing script through a proxy without changing code | NODE_USE_ENV_PROXY=1 + HTTPS_PROXY |
| The project already uses Axios | Axios + https-proxy-agent + proxy: false |
| Older project using node-fetch | node-fetch + agent |
| SOCKS5 proxy | Axios or node-fetch + socks-proxy-agent (socks5h://) |
| A different IP on every request | Rotating proxy, single address |
| The same IP for the session | Sticky proxy |
| Clear error messages matter | Axios (shows 407 with the status code) |
None of this applies to JavaScript running in the browser: browser fetch cannot change the proxy from code; the proxy is set in the browser's or operating system's settings. For setup on Windows, see Windows and Chrome Proxy Settings.
Frequently asked questions
Does Node.js's built-in fetch support proxies?
From Node.js 22.21.0 and 24.5.0, it reads the HTTP_PROXY and HTTPS_PROXY variables when the NODE_USE_ENV_PROXY=1 environment variable or the --use-env-proxy option is used. To set a proxy per request from code, use undici's fetch and ProxyAgent.
Does Axios read the HTTPS_PROXY environment variable?
In Node.js, when no proxy option is given, Axios tries to use the proxy from environment variables. For clear, predictable behaviour we recommend defining the proxy with an agent and writing proxy: false.
Can I use a different proxy for each request?
Yes. In undici you can give each fetch call a different ProxyAgent, and in Axios a different httpsAgent. Rather than creating agents on every request, create them once and reuse them as in the example above; every new agent opens its own connection pool.
How are cookies kept when using a proxy?
Built-in fetch and undici do not store cookies; you need to read the Set-Cookie header and add it to the next request as a Cookie header. In Axios you can use a cookie jar based on tough-cookie. Remember that the IP address should also stay the same for the whole session along with the cookies.
How do you pass a proxy in Puppeteer or Playwright?
Browser automation tools do not take the proxy like Node.js libraries do; they take it as an option when launching the browser. The agents in this article do not affect the browser. We show the Puppeteer side in Puppeteer and CAPTCHA.
Should I choose JavaScript or Python?
Proxies work in both languages; in Python most libraries take a proxy with a single parameter, while in Node.js the mechanism changes with the library. The choice is usually driven by the team's language and the structure of the target pages. We compare them in Web Scraping: JavaScript or Python?, and for Python libraries see HTTPX vs Requests vs AIOHTTP.
Summary
In Node.js, a proxy is passed in three different ways depending on the client: an undici dispatcher or NODE_USE_ENV_PROXY for built-in fetch, and an agent for Axios and node-fetch. For HTTPS targets, use Axios with https-proxy-agent and proxy: false, and SOCKS5 with socks-proxy-agent and the socks5h:// scheme. Take ProxyAgent and fetch from the same package, encode the password, set a timeout and run requests in small Promise.allSettled batches with retry logic. You can find plans for your data collection work on our data scraping solution page.




