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.