Most guides on proxies in Node.js recommend pulling in https-proxy-agent, socks-proxy-agent, and a couple of other packages on top. In reality, starting from Node.js version 18+, the built-in HTTP client undici already includes ProxyAgent, which covers most tasks for parsing and testing APIs through proxies. In this article, we will discuss how to set up HTTP/HTTPS tunneling through undici, organize IP rotation between requests, and what to do if you need SOCKS5 without wanting to pull in an additional dependency.
Why use undici instead of axios and request
undici is an HTTP/1.1 and HTTP/2 client written by the Node.js team and, starting from version 18, it underpins the global fetch. It has no transitive dependencies, is faster than axios in most benchmarks on connection pooling, and importantly for our topic — it has a native module for working with proxies via HTTP CONNECT tunneling.
When you are scraping Wildberries, Ozon, or testing ad creatives from different geos, every extra package in node_modules is a potential point of failure when updating Node.js. undici comes bundled with the runtime, so you get an up-to-date implementation of TLS, HTTP/2, and keep-alive without the risk that an abandoned library will break on a new major version.
Installation is straightforward:
npm install undici
If you have Node.js 20+, part of the API is available globally without import, but for ProxyAgent, import is still required as it is not exported to the global fetch by default.
Basic setup of ProxyAgent
A minimal working example of a request through a proxy looks like this:
import { ProxyAgent, fetch } from 'undici';
const proxyAgent = new ProxyAgent('http://proxy.example.com:8080');
const response = await fetch('https://httpbin.org/ip', {
dispatcher: proxyAgent,
});
const data = await response.json();
console.log(data);
The key point is the dispatcher parameter. This is not the agent option like in the old http module, but rather the undici dispatcher, which intercepts the connection before the TLS handshake and establishes a tunnel via the CONNECT method to the target host. For HTTPS requests through a proxy, this is critical: a regular HTTP proxy does not see the contents of encrypted traffic; it simply forwards bytes inside the TCP tunnel.
If you are working not with fetch but with the low-level undici.request, the syntax is similar:
import { request, ProxyAgent } from 'undici';
const proxyAgent = new ProxyAgent('http://proxy.example.com:8080');
const { statusCode, body } = await request('https://api.example.com/data', {
dispatcher: proxyAgent,
});
console.log(statusCode);
for await (const chunk of body) {
console.log(chunk.toString());
}
For production parsers that make hundreds of requests per minute, it is important not to create a new ProxyAgent for each request — this kills connection pooling. Create an instance of the agent once for the connection pool and reuse it between calls.
Proxy authentication with username and password
Most paid proxy providers issue access via a username/password pair rather than IP whitelisting. In undici, credentials are passed directly in the proxy URL:
import { ProxyAgent, fetch } from 'undici';
const login = 'user123';
const password = 'pass456';
const proxyUrl = `http://${login}:${password}@gate.proxycove.com:9000`;
const proxyAgent = new ProxyAgent(proxyUrl);
const response = await fetch('https://httpbin.org/ip', {
dispatcher: proxyAgent,
});
console.log(await response.json());
Note: If the username or password contains special characters (@, :, /), they need to be passed through encodeURIComponent, otherwise the URL parser inside undici will incorrectly parse the connection string and you will receive a 407 authentication error instead of a clear message.
For tasks where session stability is important — for example, authorization on a marketplace tied to a single IP during the session — it is more convenient to use residential proxies with a sticky session: the IP does not change for a specified time, meaning the service does not see "jumps" between geos and does not require re-captcha on every request.
Proxy rotation between requests
If you have a list of N proxies and the task is to distribute requests among them, the simplest option is round-robin without third-party queue libraries:
import { ProxyAgent, fetch } from 'undici';
const proxyList = [
'http://user:[email protected]:8000',
'http://user:[email protected]:8000',
'http://user:[email protected]:8000',
];
let currentIndex = 0;
function getNextAgent() {
const proxyUrl = proxyList[currentIndex];
currentIndex = (currentIndex + 1) % proxyList.length;
return new ProxyAgent(proxyUrl);
}
async function fetchWithRotation(url) {
const agent = getNextAgent();
return fetch(url, { dispatcher: agent });
}
for (let i = 0; i < 10; i++) {
const res = await fetchWithRotation('https://httpbin.org/ip');
console.log(await res.json());
}
This approach works well for scraping product cards or monitoring prices when each subsequent request is logically independent of the previous one. For tasks that require a high speed of IP change for each request without manual management of the list, it is easier to use proxies with automatic rotation on the provider's side — then all the code above reduces to a single static gateway URL, and the rotation is handled by a datacenter proxy or a residential pool with timer-based rotation.
If you need not just circular rotation but weighted rotation — for example, prioritizing faster proxies — add weight to the data structure:
const weightedProxies = [
{ url: 'http://user:[email protected]:8000', weight: 5 },
{ url: 'http://user:[email protected]:8000', weight: 1 },
];
function pickWeighted(list) {
const total = list.reduce((sum, p) => sum + p.weight, 0);
let rnd = Math.random() * total;
for (const p of list) {
if (rnd < p.weight) return p.url;
rnd -= p.weight;
}
return list[0].url;
}
Health check for proxies and exclusion of "dead" IPs
A proxy pool eventually "expires": some IPs get banned by the target site, some stop responding. To ensure that rotation does not send requests through dead addresses, add a health check with a timeout using the built-in AbortController:
import { ProxyAgent, fetch } from 'undici';
async function isProxyAlive(proxyUrl, timeoutMs = 5000) {
const agent = new ProxyAgent(proxyUrl);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const res = await fetch('https://httpbin.org/ip', {
dispatcher: agent,
signal: controller.signal,
});
return res.status === 200;
} catch {
return false;
} finally {
clearTimeout(timer);
}
}
async function filterAliveProxies(proxyList) {
const checks = await Promise.all(
proxyList.map(async (url) => ({ url, alive: await isProxyAlive(url) }))
);
return checks.filter((c) => c.alive).map((c) => c.url);
}
Run such a check every few minutes with a background timer and update the active pool, rather than the entire static list. This is especially important if you maintain your own proxies on cheap datacenter IPs — the drop-off rate is higher than that of managed provider pools.
SOCKS5 without third-party libraries
An important caveat: the built-in ProxyAgent in undici works via HTTP CONNECT tunneling and "out of the box" understands only HTTP/HTTPS proxies. The SOCKS5 protocol is structured differently — it is a binary handshake at the TCP level, and undici does not parse it natively. If you strictly need SOCKS5 without installing socks-proxy-agent, you can implement a minimal connector manually using the net module and the connect option in a custom Agent:
import net from 'node:net';
import { Agent, fetch } from 'undici';
function socksHandshake(socket, targetHost, targetPort) {
return new Promise((resolve, reject) => {
// Step 1: greeting, method without authentication
socket.write(Buffer.from([0x05, 0x01, 0x00]));
socket.once('data', (greeting) => {
if (greeting[1] !== 0x00) {
return reject(new Error('SOCKS5: authentication method not supported'));
}
const hostBuf = Buffer.from(targetHost, 'utf8');
const request = Buffer.concat([
Buffer.from([0x05, 0x01, 0x00, 0x03, hostBuf.length]),
hostBuf,
Buffer.from([targetPort >> 8, targetPort & 0xff]),
]);
socket.write(request);
socket.once('data', (reply) => {
if (reply[1] !== 0x00) {
return reject(new Error('SOCKS5: connection refused'));
}
resolve(socket);
});
});
});
}
const socksAgent = new Agent({
connect: (options, callback) => {
const socket = net.connect(1080, 'socks-proxy.example.com', async () => {
try {
await socksHandshake(socket, options.hostname, 443);
callback(null, socket);
} catch (err) {
callback(err, null);
}
});
socket.on('error', (err) => callback(err, null));
},
});
const response = await fetch('https://httpbin.org/ip', { dispatcher: socksAgent });
console.log(await response.json());
This is a simplified handshake without authentication via username and password (method 0x02) and without support for IPv6 — for production, it is worth adding a branch for authentication and handling different address types. But for understanding the mechanics — this is sufficient: SOCKS5 does not require TLS inside the tunnel during the handshake stage, unlike HTTP CONNECT, so the code is shorter than it seems at first glance.
In practice, SOCKS5 is more often used not for scraping web pages but for tunneling traffic from mobile applications and anti-detect browsers like Dolphin Anty or AdsPower, where the protocol is needed for operation at the socket level, not HTTP requests. If your task is specifically HTTP/HTTPS traffic in Node.js, the built-in ProxyAgent is usually sufficient, and there is no need to fiddle with a manual SOCKS5 connector.
Handling timeouts and retry logic
Proxies drop connections more often than direct connections — this is normal for residential networks, where a node can go offline at any moment. A retry wrapper with exponential backoff is needed:
import { ProxyAgent, fetch } from 'undici';
async function fetchWithRetry(url, proxyList, maxRetries = 3) {
let lastError;
for (let attempt = 0; attempt < maxRetries; attempt++) {
const proxyUrl = proxyList[attempt % proxyList.length];
const agent = new ProxyAgent(proxyUrl);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 8000);
try {
const res = await fetch(url, { dispatcher: agent, signal: controller.signal });
clearTimeout(timer);
if (res.status >= 500) throw new Error(`Server error: ${res.status}`);
return res;
} catch (err) {
clearTimeout(timer);
lastError = err;
const delay = 500 * 2 ** attempt;
await new Promise((r) => setTimeout(r, delay));
}
}
throw new Error(`All attempts exhausted: ${lastError?.message}`);
}
This wrapper also solves the rotation problem on errors: if the current proxy fails, the next attempt automatically goes to another IP from the list, rather than banging on the same dead node.
Comparison of undici ProxyAgent, https-proxy-agent, and socks-proxy-agent
| Tool | Dependencies | SOCKS5 Support | HTTP/2 | Speed |
|---|---|---|---|---|
| undici ProxyAgent | none (built into Node 18+) | no out of the box | yes | high |
| https-proxy-agent | 1-2 packages | no | no | average |
| socks-proxy-agent | 3-4 packages | yes | no | average |
For pure HTTP/HTTPS parsing with minimal dependencies, undici ProxyAgent wins due to its built-in nature and compatibility with HTTP/2. For SOCKS5 in production, it is easier to take a proven socks-proxy-agent than to maintain a custom handshake — the manual implementation from the section above is good for learning and rare edge cases, but not for heavy production with thousands of connections per hour.
Practical use cases: parsing and geo-testing
Price monitoring on marketplaces. A script using undici with proxy rotation and retry logic from the sections above is ideal for bypassing rate limits on Wildberries and Ozon: each subsequent product card request goes out with a new IP, and health checks automatically exclude banned addresses from the pool.
Testing ad creatives by geo. If you need to check how content is delivered to a user from a specific country for Google Ads or Facebook Ads, it is enough to change the proxy URL in the ProxyAgent to a node in the desired region — there is no need to spin up a browser if the check is done via API or HTML response from the server.
Checking content availability in different countries. For tasks where the site delivers different content depending on the IP, residential connections appear to the target server indistinguishable from a regular user, which reduces the risk of blocking compared to datacenter ranges. This scenario is especially relevant for mobile proxies, if the target service checks the type of network and blocks obvious datacenter subnets.
Mass checking of API endpoints. When load testing your own API through an external proxy pool, it is important to monitor connection pooling — undici by default reuses TCP connections within a single ProxyAgent, which significantly saves time on TLS handshakes when making hundreds of consecutive requests to a single host.
Conclusion
The built-in undici ProxyAgent covers most tasks with HTTP/HTTPS proxies in Node.js without the need to pull in third-party packages: authentication via URL, tunneling via CONNECT, compatibility with HTTP/2, and connection pooling work right away. Rotation and health checks for proxies can be easily assembled using pure promises and timers, and SOCKS5 can be implemented manually through a custom connect connector if necessary, although for production it is wiser to take a ready-made library specifically for SOCKS.
If your undici script is intended for scraping marketplaces or monitoring prices, consider residential proxies — they are more resilient to anti-bot systems than classic datacenter IPs and less frequently require additional captcha solutions when making mass requests to Wildberries, Ozon, or Avito.