Using a Proxy in Node.js: Axios and node-fetch

Published:

12 minute read

Acar Diveroli
Written by: Acar Diveroli
A Node cube connected to axios and fetch cards, which lead to a proxy node

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.

  1. Environment variable. The HTTP_PROXY, HTTPS_PROXY and NO_PROXY variables. They route a whole script through the proxy without code changes, but not every library reads them.
  2. Agent. Node.js's classic http and https modules open connections through an Agent object. Axios and node-fetch use these modules, so when you give them an agent that connects to a proxy (such as HttpsProxyAgent), requests go through the proxy.
  3. Dispatcher. Node.js's built-in fetch does not use the classic http module; it uses a client called undici. In undici, the object that manages connections is called a dispatcher, and for a proxy you give it a ProxyAgent.

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:

bash
npm install undici

Then import fetch and ProxyAgent from the same package:

javascript
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:

javascript
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 dispatcher

The 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:

bash
NODE_USE_ENV_PROXY=1 HTTPS_PROXY="http://user:pass@pr.proxynet.io:8000" node app.mjs

In Windows PowerShell:

powershell
$env:NODE_USE_ENV_PROXY = "1"
$env:HTTPS_PROXY = "http://user:pass@pr.proxynet.io:8000"
node app.mjs

This 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:

javascript
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:

javascript
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:

bash
npm install axios https-proxy-agent
javascript
import 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:

bash
npm install node-fetch https-proxy-agent
javascript
import 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:

bash
npm install socks-proxy-agent
javascript
import 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?

ClientHow to pass the proxyEnvironment variableSOCKS5Authentication error
Built-in fetchNODE_USE_ENV_PROXY or --use-env-proxyOnly with the flagNonefetch failed
undici fetchdispatcher: new ProxyAgent(...)With EnvHttpProxyAgentNonefetch failed, cause: request cancelled
Axiosproxy option or httpsAgent + proxy: falseIf no option is givenWith socks-proxy-agentError with status code 407
node-fetchagentNoneWith socks-proxy-agentDepends on the agent used
http.requestagentNoneWith socks-proxy-agentDepends 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.

javascript
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:

javascript
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.allSettled keeps one failed request in a batch from stopping the others. With Promise.all, a single 503 would lose the whole batch's results. In the example, the /status/503 address was marked rejected after 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:

javascript
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 agent to built-in fetch. The option is silently ignored. The right key for fetch is dispatcher.
  • Passing npm undici's ProxyAgent to global fetch. When versions don't match you get a fetch failed error; import fetch from undici too.
  • Setting HTTPS_PROXY and assuming built-in fetch will read it. It won't without NODE_USE_ENV_PROXY or --use-env-proxy.
  • Not writing proxy: false with 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://. Use socks5h:// 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.timeout in undici and timeout in 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 situationRecommendation
New project, few dependenciesundici fetch + ProxyAgent
Route an existing script through a proxy without changing codeNODE_USE_ENV_PROXY=1 + HTTPS_PROXY
The project already uses AxiosAxios + https-proxy-agent + proxy: false
Older project using node-fetchnode-fetch + agent
SOCKS5 proxyAxios or node-fetch + socks-proxy-agent (socks5h://)
A different IP on every requestRotating proxy, single address
The same IP for the sessionSticky proxy
Clear error messages matterAxios (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.

Ask ChatGPTAsk Claude