---
title: "Using a Proxy in Node.js: Axios and node-fetch"
description: "How to use a proxy in Node.js with Axios, node-fetch and built-in fetch, with working code. Includes authentication, SOCKS5, rotation and error handling."
url: https://proxynet.io/blog/nodejs-proxy
date: 2026-09-13
author: "Acar Diveroli"
category: "Tutorial, Integration"
lang: en
---

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

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.

> **Note: Short answer**
>
> For built-in fetch, use undici's `fetch` and `ProxyAgent` together, or start Node.js with `NODE_USE_ENV_PROXY=1` and the `HTTPS_PROXY` environment variable. In Axios, for HTTPS addresses, pass an agent created with `https-proxy-agent` as `httpsAgent` and set `proxy: false`. In node-fetch, the same agent goes into the `agent` option. For SOCKS5, use the `socks-proxy-agent` package.

## 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?](/blog/what-is-a-proxy-server). Translating cURL commands into fetch and Axios (headers, body, form data) is the topic of our [cURL in JavaScript](/blog/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](https://nodejs.org/learn/http/enterprise-network-configuration), 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](https://axios-http.com/docs/req_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](/blog/webrtc-dns-leak). Before choosing between SOCKS5 and an HTTP proxy, see [SOCKS vs HTTP Proxy](/blog/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](/blog/proxy-authentication-methods).

## 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](https://proxynet.io/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](https://proxynet.io/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](/blog/http-status-codes-web-scraping). How the concurrency value actually affects speed is covered in [Concurrency vs Parallelism](/blog/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 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](/blog/windows-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](/blog/puppeteer-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?](/blog/web-scraping-javascript-vs-python), and for Python libraries see [HTTPX vs Requests vs AIOHTTP](/blog/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](/data-scraping) page.
