Skip to content
ProxyForge

Node.js proxy setup for fetch, undici and axios

ProxyForge engineeringUpdated 7 min read

A Node.js proxy setup depends on which HTTP stack your code uses. Built-in fetch and undici take a dispatcher, so you pass new ProxyAgent(proxyUrl) from the undici package; axios and anything built on the http and https modules take an agent such as HttpsProxyAgent from https-proxy-agent. Recent Node releases can also route fetch and the http modules through HTTP_PROXY and HTTPS_PROXY when started with NODE_USE_ENV_PROXY=1, with no code changes.

This guide covers each option with ES module examples checked against Node 22, then SOCKS5, timeouts, retries, concurrency and exit-IP checks. Every example reads the connection string from PROXY_URL, shaped like http://USERNAME:[email protected]:PORT. Your dashboard generates the exact string for the country and session mode you choose, with any special characters in the password already percent-encoded.

fetch and undici with ProxyAgent

fetch in Node is implemented by undici, and undici's ProxyAgent is the dispatcher that sends requests through an HTTP proxy. It reads credentials from the URL, URL-decodes them, and sends them as Proxy-Authorization on the CONNECT request for HTTPS targets.

import { ProxyAgent, fetch } from 'undici';

const dispatcher = new ProxyAgent(process.env.PROXY_URL);

const response = await fetch('https://api.ipify.org?format=json', {
  dispatcher,
  signal: AbortSignal.timeout(30_000),
});
console.log(await response.json());

Note that fetch is imported from undici too, not used from the global scope. Node bundles its own copy of undici, and a dispatcher from a different major version of the npm package is not guaranteed to work with the global fetch. We hit exactly that on Node 22 with undici 8: passing its ProxyAgent to the global fetch failed with invalid onRequestStart method, while undici's own fetch worked. Importing both from the same package removes the version question.

When a proxy request fails, fetch throws a generic TypeError: fetch failed. The useful part is in the cause chain: a rejected CONNECT appears two levels down as Proxy response (407) !== 200 when HTTP Tunneling. Log the whole chain, not just the top-level message, or every proxy problem looks the same in your logs.

Environment-driven configuration

EnvHttpProxyAgent and a global dispatcher

If proxy settings should come from the environment, undici's EnvHttpProxyAgent reads HTTP_PROXY, HTTPS_PROXY and NO_PROXY (and their lowercase forms). Combined with RetryAgent and installed as the global dispatcher, it gives every undici fetch in the process proxying and retries without touching call sites:

import { EnvHttpProxyAgent, RetryAgent, fetch, setGlobalDispatcher } from 'undici';

setGlobalDispatcher(
  new RetryAgent(new EnvHttpProxyAgent(), {
    maxRetries: 3,
    minTimeout: 500,
    statusCodes: [429, 502, 503, 504],
  }),
);

const response = await fetch('https://api.ipify.org?format=json', {
  signal: AbortSignal.timeout(30_000),
});
console.log(await response.json());

Set the variables in the process environment:

export HTTPS_PROXY="$PROXY_URL"
export HTTP_PROXY="$PROXY_URL"
export NO_PROXY="localhost,127.0.0.1,.internal.example.com"

NO_PROXY keeps internal traffic, such as health checks, metadata endpoints and your own APIs, off the proxy and off a per-GB bill. RetryAgent retries idempotent methods on network errors and on the listed status codes with exponential backoff, and it honors Retry-After. Leave 403 and 407 out of statusCodes: a 407 is a credential problem, and a 403 from the target is usually a block that an immediate retry will not fix. Proxy 403 and 429 errors covers how to tell the two apart.

Node's built-in proxy support: NODE_USE_ENV_PROXY

Node now has proxy support of its own. When started with NODE_USE_ENV_PROXY=1, or with the --use-env-proxy flag, Node reads HTTP_PROXY, HTTPS_PROXY and NO_PROXY at start-up and routes both the global fetch and the default agents of the http and https modules through them:

NODE_USE_ENV_PROXY=1 HTTPS_PROXY="$PROXY_URL" node app.mjs
HTTPS_PROXY="$PROXY_URL" node --use-env-proxy app.mjs

According to the Node documentation, the flag and full support arrived in Node 24.5 and were backported to 22.21. Node 24.14 and 25.4 added http.setGlobalProxyFromEnv() for enabling it at runtime. The feature is marked "active development" (stability 1.1), so pin your Node version and read the release notes when you upgrade. It is the right choice when you want to proxy a program you did not write, or a codebase with many HTTP call sites, without adding a dependency. When you need per-request control, such as different sessions for different jobs, use an explicit dispatcher or agent instead. The Node.js HTTP documentation has the full NO_PROXY syntax.

axios with https-proxy-agent

axios uses Node's http and https modules, so it takes agents rather than dispatchers. The dependable pattern is an explicit agent for each scheme and proxy: false:

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

const proxyUrl = process.env.PROXY_URL;

const client = axios.create({
  httpAgent: new HttpProxyAgent(proxyUrl),
  httpsAgent: new HttpsProxyAgent(proxyUrl),
  proxy: false,
  timeout: 30_000,
});

const { data } = await client.get('https://api.ipify.org?format=json');
console.log(data);

proxy: false matters. Without it, axios also reads HTTP_PROXY and HTTPS_PROXY from the environment and applies its own proxy handling on top of your agent, so a variable left in a container image can quietly change where traffic goes.

axios also has a built-in proxy option that takes host, port and auth. Its handling of HTTPS targets has changed over time: releases before 1.16.1 could send HTTPS request data to an HTTP proxy without a CONNECT tunnel, which the 1.16.1 release notes describe as a cleartext leak. Current releases tunnel correctly. If you use the built-in option, pin a current axios version; if you cannot control the version, or need SOCKS, use agents as above.

SOCKS5 with socks-proxy-agent

Every ProxyForge line also accepts SOCKS5. For axios and the http modules, socks-proxy-agent provides one agent for both schemes:

import axios from 'axios';
import { SocksProxyAgent } from 'socks-proxy-agent';

const agent = new SocksProxyAgent(process.env.SOCKS_PROXY_URL);

const { data } = await axios.get('https://api.ipify.org?format=json', {
  httpAgent: agent,
  httpsAgent: agent,
  proxy: false,
  timeout: 30_000,
});
console.log(data);

Use socks5h:// in SOCKS_PROXY_URL so hostnames are resolved at the exit rather than on your machine. With socks5://, your local resolver sees every lookup and a CDN may route you to a node near your server instead of near the exit, which matters for geo-targeted results.

For fetch, undici 7.23 added a Socks5ProxyAgent that resolves hostnames at the proxy and accepts credentials in the URL. It is marked experimental and prints a warning when constructed. It worked in our tests, but for production SOCKS traffic today, the agent-based route is the more settled choice. For most HTTP work there is no reason to prefer SOCKS5 over an HTTP proxy at all: HTTPS is carried inside a CONNECT tunnel either way.

Timeouts, retries, concurrency and rotation

Timeouts

Neither fetch nor axios has a useful default timeout for Node.js proxy traffic. With fetch, pass signal: AbortSignal.timeout(ms), which bounds the whole request, including reading the body. With axios, set timeout. Keep connection timeouts short: a gateway that does not accept a connection in a few seconds is unlikely to succeed after thirty.

Retries for axios

axios has no built-in retry. A small wrapper with backoff and Retry-After support covers most needs:

const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

export async function withRetry(request, { attempts = 4, baseDelayMs = 500 } = {}) {
  for (let attempt = 1; ; attempt += 1) {
    try {
      return await request();
    } catch (error) {
      const status = error.response?.status;
      const retryable = status === undefined || status === 429 || status >= 500;
      if (!retryable || attempt >= attempts) throw error;
      const retryAfterSeconds = Number(error.response?.headers?.['retry-after']);
      const delay = Number.isFinite(retryAfterSeconds)
        ? retryAfterSeconds * 1000
        : baseDelayMs * 2 ** (attempt - 1) * (0.5 + Math.random());
      await sleep(delay);
    }
  }
}

Call it as await withRetry(() => client.get(url)). A 407 from the gateway arrives as error.response.status === 407, which this wrapper does not retry, as it should not: wrong credentials do not become right on the third attempt. Only wrap idempotent requests.

Concurrency and rotation

Both undici and Node's agents keep connections alive and reuse them. Through a proxy, a reused connection to an HTTPS target is a reused CONNECT tunnel, and on most gateways the exit IP is fixed when the tunnel opens. So consecutive requests on one pooled connection usually share an exit, even on an endpoint set to rotate per request.

Use that deliberately. For a sequence that should share an identity, such as a login and the pages behind it, use a sticky session and one agent per identity. For a different IP per request, use the rotating endpoint and give each request a fresh connection, for example with an agent per request. Rotating vs sticky proxies covers choosing between them.

Bound concurrency explicitly with a small limiter rather than firing thousands of promises at once. The limit you want is usually per target site, not per process, and it should be well below what the proxy can carry; the target's tolerance is the constraint.

Verifying the exit IP

A misconfigured Node.js proxy rarely throws; requests succeed from the wrong address. Check the exit at start-up with the same client, agent or dispatcher your job will use, and log it:

import { ProxyAgent, fetch } from 'undici';

const echo = 'https://api.ipify.org?format=json';
const direct = await (await fetch(echo)).json();
const proxied = await (await fetch(echo, { dispatcher: new ProxyAgent(process.env.PROXY_URL) })).json();

if (direct.ip === proxied.ip) {
  throw new Error(`Traffic is not going through the proxy (exit IP ${proxied.ip})`);
}
console.log(`Exit IP through proxy: ${proxied.ip}`);

If the process runs with NODE_USE_ENV_PROXY=1 or installs a global dispatcher, the "direct" request may go through the proxy as well, so compare against the server's known public address instead.

Which proxy line fits a Node.js workload

HTTP clients fetch only what you request, so bandwidth per call is low and per-GB lines are economical for most Node.js proxy work. The target decides the line:

Workload Line Notes
Consumer sites and APIs that score IP reputation Residential Rotating per request, or sticky up to 60 minutes
Long-lived sessions and accounts ISP Static, dedicated, billed per address
High-volume calls to tolerant endpoints Datacenter Dedicated addresses from our own ASN
Mobile app backends and carrier-dependent responses Mobile Rotating, or sticky up to 30 minutes

For a side-by-side of the Python equivalent, see the Python Requests proxy guide.

Using ProxyForge from Node.js

Every ProxyForge line accepts HTTP, HTTPS and SOCKS5 with username and password or IP allowlist authentication, so each pattern above works on every line; only the connection string changes. The dashboard generates it for the country and session mode you choose, and organization roles keep account changes with the few people who should make them.

Billing is pay-as-you-go from a prepaid wallet, from 1 GB or 1 IP, with published rates on the pricing page. If you are moving a Node.js service from another provider, the migration process mirrors your current endpoint and session syntax so the code does not change while you compare the two.

FAQ

Related questions

Why does Node's fetch ignore the HTTPS_PROXY environment variable?

Built-in fetch does not read proxy variables by default. Enable it with NODE_USE_ENV_PROXY=1 or the --use-env-proxy flag on a Node release that supports them, or pass an undici EnvHttpProxyAgent as the dispatcher.

Do I need https-proxy-agent if I only use fetch?

No. https-proxy-agent and socks-proxy-agent plug into Node's http and https modules, which axios and many older libraries use. fetch is built on undici and takes an undici dispatcher such as ProxyAgent instead.

How do I get a different IP per request in Node.js?

Use a rotating gateway endpoint and avoid reusing pooled connections, because most gateways choose the exit when the tunnel opens and keep-alive reuses tunnels. Creating a fresh agent per request, or disabling keep-alive, gives the gateway a new connection each time.

What does 'Proxy response (407) !== 200 when HTTP Tunneling' mean?

undici sent a CONNECT to the proxy and the proxy rejected the credentials or the source IP. Check that the username and password are percent-encoded in the URL, or that the machine's egress IP is on the allowlist.

Run it on a network you can account for

Order from 1 GB or 1 IP with no monthly minimum, or talk to an engineer about your workload first.

One business day, from a named engineer.