---
title: "Using proxies in Node.js: fetch, undici, axios and got"
url: https://proxy.wiki/guides/proxies-in-nodejs/
type: Guide
author: "proxy.wiki editorial"
published: 2026-08-29
updated: 2026-08-29
site: proxy.wiki
topics: ["Web scraping"]
license: CC BY 4.0 — quote freely with attribution to https://proxy.wiki/
---

# 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.

## Key takeaways

- Node ignores HTTP_PROXY and HTTPS_PROXY entirely. Verified on 24.2.0: the request went direct and the proxy logged nothing.
- Global fetch needs setGlobalDispatcher. Passing a dispatcher per call throws UND_ERR_INVALID_ARG, reported as "fetch failed".
- Importing fetch from undici accepts a per-call dispatcher; the global one does not. Same name, different options.
- With axios and an agent, set proxy:false as well, or axios applies its own handling on top and the two conflict.
- Reuse one ProxyAgent. Five requests through a shared agent opened 1 CONNECT; a new agent per request opened 5.
- A 403 through a working proxy is the destination refusing you. undici's low-level request sends no User-Agent, and some APIs require one.

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](/guides/proxy-environment-variables/) 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:pass@gateway.example: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:pass@gateway.example: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:pass@gateway.example: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](/glossary/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:pass@gateway.example: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](/glossary/exit-node/) 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](/glossary/rotating-proxy/) this reports one [exit node](/glossary/exit-node/) at one moment, not a property of the [pool](/glossary/proxy-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](/glossary/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](/glossary/bandwidth-billing/).

## A note on SOCKS

Everything above uses an HTTP proxy. For a [SOCKS5](/glossary/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](https://undici.nodejs.org/#/docs/api/ProxyAgent)
2. [Node.js: the http and https modules and the agent option](https://nodejs.org/api/http.html)
3. [RFC 9110: HTTP Semantics, the CONNECT method and status 407](https://www.rfc-editor.org/rfc/rfc9110.html)
