Back to blog

How to Use a Proxy With node-fetch: Setup, Rotation, and Troubleshooting Guide

node-fetch proxy routes your fetch requests through an intermediary server, so the target site sees the proxy's IP instead of yours. It's the standard fix for IP blocks, geo-restrictions, and rate limiting in Node.js scraping. The catch: neither node-fetch nor Node's native fetch supports proxies natively, so you need an external agent library to bridge the gap.

Node Fetch Proxy

TL;DR

  • node-fetch has no built-in proxy option, so you wire in an agent (typically https-proxy-agent) and pass it via the { agent } parameter
  • Node's native fetch ignores { agent } entirely because it runs on Undici, not the http(s).Agent API. Use ProxyAgent or EnvHttpProxyAgent from Undici, or enable NODE_USE_ENV_PROXY=1 on Node 22.21+ or 24.0+
  • For production scraping, rotate IPs through a residential proxy gateway like Decodo and reuse the same agent within a session for cart flows or paginated requests
  • TLS errors like UNABLE_TO_VERIFY_LEAF_SIGNATURE usually point to a corporate MITM proxy or a self-signed cert. Load the right CA instead of disabling verification.

Prerequisites and project setup

This guide assumes you're comfortable with Node.js basics and understand what a proxy server does at a high level. If you're new to scraping more broadly, the JavaScript web scraping tutorial covers the basics.

You'll need Node.js. The version depends on which fetch you're using:

  • node-fetch v2: Node 4 or later, CommonJS-compatible
  • node-fetch v3: Node 12.20.0, 14.14, or 16.0 minimum, ESM-only
  • Native fetch: shipped experimentally in Node 18, stable from Node 21
  • NODE_USE_ENV_PROXY for native fetch: Node 22.21.0+ or 24.0.0+ (official Node.js feature)

Note: This guide uses macOS and Terminal commands throughout. If you're on Windows, you can install Node.js directly from the official download page.

If you don't already have Node.js installed, you can install it from Terminal using `nvm`:

curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.4/install.sh | bash
\. "$HOME/.nvm/nvm.sh"
nvm install 24
Downloading and installing node

Verify the installation:

node -v
npm -v

You should see version numbers returned for both commands.

Version numbers for both commands.

Next, create a new project directory and initialize a Node.js project:

mkdir node-fetch-proxy && cd node-fetch-proxy
npm init -y

Your directory structure should now look like this:

node-fetch-proxy/
└── package.json

Open package.json and add "type": "module". This tells Node to treat .js files as ESM (ECMAScript modules), matching node-fetch v3.

{
"name": "fetch-proxy",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "module",
"dependencies": {
"dotenv": "^17.4.2",
"https-proxy-agent": "^9.0.0",
"node-fetch": "^3.3.2"
}
}

Next, install the dependencies:

npm install node-fetch https-proxy-agent dotenv

A couple of things here:

  • node-fetch brings the fetch() API to Node.js
  • https-proxy-agent allows requests to run through an HTTP or HTTPS proxy
  • dotenv is optional but useful for storing proxy credentials and other sensitive values in environment variables instead of hardcoding them

Once the installation finishes, create an index.js file in the root of the project directory.

For the examples in this guide, we'll use Decodo's IP endpoint to quickly verify whether requests are going through your real connection or a proxy.

touch index.js
cat > index.js <<'EOF'
import fetch from 'node-fetch';
const response = await fetch('https://ip.decodo.com/json');
const data = await response.json();
console.log(data);
EOF
Node Fetch Proxy

This sends a request to ip.decodo.com and returns information about your current IP address.

Run the file with:

node index.js

You should see a JSON response containing your IP and location details. That gives you a clean baseline. Everything else in this guide is about changing what appears in that response.

Integrating https-proxy-agent with node-fetch

Before jumping into the code, it helps to understand what the proxy agent is actually doing.

In Node.js, an HTTP agent manages how network requests are handled behind the scenes, including connection reuse, keep-alive behavior, and where requests are routed. By default, requests go directly to the target server. A proxy agent changes that behavior by routing requests through a proxy server first, which then forwards the request to the final destination.

The https-proxy-agent package does exactly this. Despite the name, the HttpsProxyAgent class handles both HTTP and HTTPS target URLs, so you don't need a separate agent for each.

The basic pattern looks like this:

const agent = new HttpsProxyAgent('http://username:password@proxy-host:port');
fetch('https://target-url', { agent });

This reads as: "create an agent that tunnels through this proxy, then use it for this fetch request". The { agent } option is the bridge. That's the one parameter node-fetch needs to route through the proxy.

Here's the full ESM example, hitting an IP echo endpoint to verify the proxy works:

/ index.js
import fetch from 'node-fetch';
import { HttpsProxyAgent } from 'https-proxy-agent';
const proxyUrl = 'http://YOUR_PROXY_USERNAME:YOUR_PROXY_PASSWORD@gate.decodo.com:7000';
const agent = new HttpsProxyAgent(proxyUrl);
const response = await fetch('https://ip.decodo.com/json', {
agent
});
const data = await response.json();
console.log(data);

The proxy URL embeds credentials inline, which is convenient for examples but not what you want in production (more on that shortly). The target is ip.decodo.com/json, an IP-echo endpoint that returns the IP, ISP, city, and country of whoever made the request. If the proxy is working, you'll see the proxy's IP and location in the response, not your own.

If you're using CommonJS with node-fetch v2, the structure is almost identical. The main difference is using require() and wrapping the request in an async IIFE because top-level await isn't available:

// index.cjs
const fetch = require('node-fetch');
const { HttpsProxyAgent } = require('https-proxy-agent');
const proxyUrl = 'http://YOUR_PROXY_USERNAME:YOUR_PROXY_PASSWORD@gate.decodo.com:7000';
const agent = new HttpsProxyAgent(proxyUrl);
(async () => {
const response = await fetch('https://ip.decodo.com/json', {
agent
});
const data = await response.json();
console.log(data);
})();

If you're weighing fetch against other HTTP clients in Node.js, cURL in JavaScript covers the trade-offs.

Get started with reliable residential proxies

115M+ ethically-sourced IPs in 195+ locations, with rotation and sticky sessions built in.

Handling proxy authentication and credentials

Proxy providers require authentication before they'll route your requests. In practice, there are two common approaches:

1. Authenticate with a username and password on each request

2. Pre-register your server's IP address with the provider (IP whitelisting)

Most setups use credentials, and that's where practical issues such as special characters in passwords and accidentally exposing credentials in source control show up.

Proxy credentials are usually embedded directly inside the proxy URL. The problem is that certain characters have special meaning inside URLs. For example:

  • @ separates credentials from the hostname
  • : separates the username from the password
  • / and # can also break parsing

If those characters appear inside your password, the proxy URL can become invalid.

To avoid this, wrap the credentials with encodeURIComponent() before constructing the URL:

const username = encodeURIComponent(process.env.PROXY_USERNAME);
const password = encodeURIComponent(process.env.PROXY_PASSWORD);
const proxyUrl = `http://${username}:${password}@gate.decodo.com:7000`;

Hardcoding credentials directly into source files is fine for quick tests, but it's not ideal for real projects. A safer approach is storing them in environment variables.

Create a .env file in the root of your project:

PROXY_USERNAME=your_username
PROXY_PASSWORD=your_password

Add .env to your .gitignore immediately. Then load it at the top of your script:

import 'dotenv/config';

In CI/CD, skip the .env file and inject the same variables as secrets in GitHub Actions, GitLab CI, or wherever your pipeline runs. The code stays identical because it just reads process.env.

If you go the IP whitelisting route instead, you register your server's public IP address with the proxy provider through their dashboard. No credentials in code, no URL encoding to worry about.

The downside is that it only works reliably when your server has a stable public IP address. For most modern applications and cloud environments, credential-based authentication is usually the more flexible option.

Using proxies with Node.js native fetch

Native fetch in Node.js doesn't use the { agent } option. You can pass it anyway, but the request will still go out through your real IP address.

The reason is that Node's built-in fetch is powered by Undici, which uses its own dispatcher system instead of the older http(s).Agent API. Since https-proxy-agent is built around http(s).Agent, native fetch simply ignores it.

That leaves you with three options:

Option 1: Global ProxyAgent

This replaces the default dispatcher for the entire process. Every subsequent fetch() call routes through the proxy automatically:

import { setGlobalDispatcher, ProxyAgent } from 'undici';
const proxyAgent = new ProxyAgent(
'http://YOUR_PROXY_USERNAME:YOUR_PROXY_PASSWORD@gate.decodo.com:7000'
);
setGlobalDispatcher(proxyAgent);
const response = await fetch('https://ip.decodo.com/json');
console.log(await response.json());

Option 2: Per-request dispatcher

If you only want certain requests proxied, pass the dispatcher directly into fetch():

import { ProxyAgent } from 'undici';
const proxyAgent = new ProxyAgent(
'http://YOUR_PROXY_USERNAME:YOUR_PROXY_PASSWORD@gate.decodo.com:7000'
);
const response = await fetch('https://ip.decodo.com/json', {
dispatcher: proxyAgent
});

In TypeScript projects, the dispatcher may not appear in the standard fetch typings yet, even though Undici supports it at runtime.

Option 3: NODE_USE_ENV_PROXY (Node 22.21+ or 24.0+)

Node.js 24 introduced built-in proxy support through environment variables.

Run your script like this:

NODE_USE_ENV_PROXY=1 \
HTTPS_PROXY=http://YOUR_PROXY_USERNAME:YOUR_PROXY_PASSWORD@gate.decodo.com:7000 \
NO_PROXY=localhost,127.0.0.1 \
node index.js

No code changes required. This is especially useful in containerized or cloud environments where proxy settings are injected through environment variables.

Here's how the approaches compare:

Approach

Node version

Best for

Limitation

node-fetch + https-proxy-agent

Node 12.20+

Existing node-fetch projects and fine-grained per-request proxy control

Extra dependency and ESM/CommonJS version differences

Undici ProxyAgent (global)

Node 18+ (stable in 21+)

Native fetch projects where every request should use the proxy

Affects every fetch() call in the process

Undici ProxyAgent (per-request)

Node 18+ (stable in 21+)

Mixing proxied and direct requests in the same application

dispatcher isn't fully reflected in TypeScript typings yet

NODE_USE_ENV_PROXY

Node 22.21+ or 24.0+

Containerized or infrastructure-driven environments with env-based proxy configuration

Requires recent Node, less programmatic control

The practical rule: on Node 20 or below, stick with node-fetchhttps-proxy-agent. On Node 21+, native fetch + Undici ProxyAgent gets you there with one fewer dependency. On Node 22.21+ or 24.0+, the env-variable approach is hard to beat.

Customizing headers and user agents to avoid detection

When a target server receives your request, it sees more than just your IP address. Every HTTP request also includes headers that describe the client: which browser you're claiming to be, what content types you accept, which languages you prefer, and more.

Real browsers send these headers in fairly predictable combinations and formats. By contrast, a default Node.js fetch() request sends a sparse and highly distinctive header set that doesn't resemble normal browser traffic. Anti-bot systems routinely use those differences as detection signals.

That means a request can still get flagged even if it's coming through a clean residential proxy. The most important headers are usually:

  • User-Agent (browser and operating system information)
  • Accept (content types the client can process)
  • Accept-Language (preferred languages)
  • Accept-Encoding (supported compression formats)
  • Referer (where the request originated from)

Combine custom headers with your proxy agent:

const response = await fetch('https://target.example/page', {
agent,
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Language': 'en-US,en;q=0.9',
'Accept-Encoding': 'gzip, deflate, br',
},
});

For rotation, keep an array of realistic User-Agent strings and pick one at random per request. The point isn't to be clever about it, just not to send the same headers on every request from every IP.

There's a ceiling to what header rotation can do. Modern anti-bot systems also fingerprint your TLS handshake (JA3, JA4), HTTP/2 frame settings, and the order of your headers. Node's default TLS stack has a fingerprint that doesn't match any real browser, so a determined detection system will flag you no matter how realistic your User-Agent looks. 

For targets with serious bot protection, header rotation alone won't get you through. The cleaner answer is to offload the problem entirely. Decodo's Web Scraping API handles all of that automatically, so you keep your fetch code clean and let the proxy layer deal with fingerprinting.

Rotating proxies and managing sessions

Rotation means a different IP per request, so a target can't ban you based on traffic volume from any single address. Sessions mean the same IP for multiple requests, so a target sees a coherent visit rather than disconnected hits. The choice depends on whether the request has state attached.

For independent requests (product pages, search results, anything without state), rotate. The simplest version picks a random proxy from a pool:

const proxies = [
'http://user:[email protected]:10001',
'http://user:[email protected]:10002',
'http://user:[email protected]:10003',
];
function getRandomProxy() {
return proxies[Math.floor(Math.random() * proxies.length)];
}
const agent = new HttpsProxyAgent(getRandomProxy());
const response = await fetch('https://target.example/page', { agent });

Round-robin works too and gives more predictable distribution. But maintaining your own proxy pool is an old problem. Modern residential providers route everything through a single gateway and rotate server-side. With Decodo, you point at gate.decodo.com:7000 and every request gets a fresh IP automatically:

const agent = new HttpsProxyAgent(`http://${user}:${pass}@gate.decodo.com:7000`);

For stateful workflows (logging in, adding to cart, scraping page 7 of a paginated list), rotation breaks things. You want the same IP for the duration of the session. The reliable approach is provider-managed sticky sessions, where you encode the session into the proxy username:

const stickyUser = `user-${username}-session-cart42-sessionduration-30`;
const proxyUrl = `http://${stickyUser}:${password}@gate.decodo.com:7000`;
const agent = new HttpsProxyAgent(proxyUrl);

This reads as: "use the user account username, keep the same IP for the session identified as cart42, and hold that IP for 30 minutes". Every request through that agent now comes from the same IP for the duration. Change session-cart42 to a new value (or wait for it to expire), and you'll get a different IP.

Retry logic ties both patterns together. Proxy connections fail more often than direct ones because the IP at the end might go offline, get banned mid-session, or just time out. Exponential backoff with jitter is the standard retry pattern:

async function fetchWithRetry(url, options, maxAttempts = 3) {
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
const response = await fetch(url, options);
if (response.ok) return response;
if (response.status === 429 || response.status >= 500) {
const delay = Math.min(1000 * 2 ** attempt + Math.random() * 1000, 10000);
await new Promise(r => setTimeout(r, delay));
continue;
}
return response;
} catch (err) {
if (attempt === maxAttempts) throw err;
const delay = Math.min(1000 * 2 ** attempt + Math.random() * 1000, 10000);
await new Promise(r => setTimeout(r, delay));
}
}
}

The jitter (the random component) matters. If 100 of your worker processes all retry at exactly 2 seconds, you've just synchronized a thundering herd. The randomness spreads them out.

Understanding proxy types: Residential, datacenter, ISP, and mobile

Not all proxies are equal. The four main types differ in where the IPs come from, which affects how much trust target sites give them.

  • Datacenter proxies come from servers in data centers. Fast and cheap, but the ASN ranges are well-known and many sites flag them on sight.
  • Residential proxies route through real home internet connections, so they appear to come from regular users. Slower and pricier than datacenter, but they pass through anti-bot systems that block datacenter IPs immediately.
  • ISP proxies are a hybrid. The IPs are registered with residential ISPs but hosted in data centers, so target sites treat them as residential while you get datacenter-grade stability. 
  • Mobile proxies use carrier IPs from 4G and 5G networks. Carrier NAT means thousands of real users share a single IP, so banning one effectively bans innocent users. Sites avoid that, which makes mobile proxies the hardest to block.

Type

Speed

Cost

Detection risk

Best for

Datacenter

Fast

Low

High

Low-risk targets, internal APIs

Residential

Medium

Medium

Low

Sites with aggressive anti-bot

ISP

Fast

Medium-High

Low

Long sessions, sustained scraping

Mobile

Slow

High

Very low

Hardest targets

Handling TLS, certificates, and common connection errors

When you start putting node-fetch behind a proxy, a handful of errors will show up often enough to learn by heart. The pattern is usually clearer if you start from what you observe rather than the error code itself.

  • The request fails with a TLS certificate error. You'll see something like UNABLE_TO_VERIFY_LEAF_SIGNATURE or SELF_SIGNED_CERT_IN_CHAIN. This means your proxy is intercepting TLS (typically a corporate MITM proxy that re-signs traffic with its own CA) or you're using a self-signed certificate somewhere in the chain. The wrong fix is setting NODE_TLS_REJECT_UNAUTHORIZED=0. That disables certificate verification globally for your process, which means a man-in-the-middle attacker could swap any cert and you'd never know. The right fix is to load the actual CA certificate your proxy uses. To resolve it, point Node at the CA via the environment variable:
NODE_EXTRA_CA_CERTS=/path/to/corporate-ca.crt node index.js

Or pass it programmatically to the agent:

import fs from 'node:fs';
const ca = fs.readFileSync('/path/to/corporate-ca.crt');
const agent = new HttpsProxyAgent(proxyUrl, { ca });

Verification works as designed, and you keep the security guarantees.

  • The connection is refused immediately. You'll see ECONNREFUSED. The proxy server isn't accepting your connection. Check that the URL is correct, the port is right (Decodo's residential gateway is 7000), and the proxy is actually running. A quick curl -x http://gate.decodo.com:7000 https://ip.decodo.com/json will fail in the same way if it's a connectivity problem rather than something in your code.
  • The request hangs and times out. You'll see ETIMEDOUT. The proxy is reachable but slow, the target is slow, or both. Try a different geographic proxy location closer to the target, increase your timeout, or check whether the target is rate-limiting you in a way that looks like a timeout.
  • The proxy returns 407. Authentication required. Your credentials are missing, wrong, or not making it into the request. URL-encoding issues are the usual culprit when the credentials look correct but still fail. Special characters like @, :, /, or # need encodeURIComponent() treatment before they go into the URL.
  • The target returns 403. This one's different from the rest. The proxy worked, the IP got through, and then the target's anti-bot kicked in. The fix isn't in your proxy code, it's in your strategy: rotate IPs faster, add realistic headers, or move to a higher-trust proxy type (residential or ISP instead of datacenter).

Testing and troubleshooting proxy connections

When something breaks, work through it in order rather than guessing. Each step verifies one assumption.

  1. Is the proxy reachable? Run curl -v -x http://gate.decodo.com:7000 https://ip.decodo.com/json -U user:pass from the same machine your Node code runs on. If curl fails, your code can't possibly succeed.
  2. Does authentication work? Check the curl response. A 407 means credentials are wrong or malformed. A real response with an IP means auth is fine.
  3. Is the IP what you expect? Check the returned country, city, and ISP. If you wanted Germany and got Brazil, your geo parameters aren't being passed correctly.
  4. Does it work against the actual target? If steps 1-3 passed but this fails, the target is blocking the proxy IP, not your setup.

Quick checklist for when nothing else fits:

  • Missing http:// prefix in the proxy URL
  • Wrong port 
  • Special characters in credentials are not URL-encoded
  • Firewall blocking the outbound proxy port
  • node-fetch v3 imported with require() (it's ESM-only)
  • { agent } passed to native fetch (it's ignored, use { dispatcher } instead)

Limitations of node-fetch proxies and alternative approaches

node-fetch + proxy gets you a long way, but it has a ceiling.

First, it can't execute JavaScript. If the page is built with React, Next.js, or any client-side framework, fetch returns the empty shell that ships from the server, not the populated page a browser would see. For JS-rendered pages, you need a headless browser like Playwright or Puppeteer, or a rendering API.

Second, basic fetch lacks browser-level fingerprints. Even with rotated headers and residential IPs, your TLS handshake, HTTP/2 settings, and absence of a real browser stack are detectable. Sophisticated anti-bot systems (Cloudflare Bot Management, DataDome, PerimeterX) catch this. Two realistic paths for those targets:

  • A headless browser, which adds real browser fingerprints but is heavy on resources and slow
  • A scraping API like Decodo's Web Scraping API, which handles proxies, headers, fingerprints, JavaScript rendering, and retries in a single HTTP call

For simple targets, stick with node-fetch + proxy. If your work is mostly parsing static HTML in Node.js, web scraping with Cheerio and Node.js pairs naturally with this setup. When the target fights back, move up the stack.

Final thoughts

If there's one thing to take from all of this, it's that node-fetch and Node's native fetch behave differently enough that they should almost be treated as separate libraries. 

One uses { agent }, the other uses { dispatcher }. One works with https-proxy-agent, the other doesn't. Knowing which fetch you're using and which proxy mechanism it expects is more than half of getting a node-fetch proxy setup right.

The rest is plumbing you'd rather not maintain forever. Tools like Decodo's Web Scraping API handle the proxy layer, the rendering, and the anti-bot work for you, so your fetch code stays focused on parsing what comes back.

Scrape pages that block fetch requests

Get rendered HTML from Cloudflare, DataDome, and PerimeterX-protected sites without managing rotation, headers, or retries yourself. Data delivered in HTML, JSON, CSV, XHR, PNG, or Markdown.

About the author

Justinas Tamasevicius

Director of Engineering

Justinas Tamaševičius is Director of Engineering with over two decades of expertise in software development. What started as a self-taught passion during his school years has evolved into a distinguished career spanning backend engineering, system architecture, and infrastructure development.

Connect with Justinas via LinkedIn.

All information on Decodo Blog is provided on an as is basis and for informational purposes only. We make no representation and disclaim all liability with respect to your use of any information contained on Decodo Blog or any third-party websites that may belinked therein.

Frequently asked questions

Does node-fetch have built-in proxy support?

No. node-fetch ships without proxy configuration. You add it with an external agent library, typically https-proxy-agent, passed via the { agent } option.

What's the difference between node-fetch and Node's native fetch for proxy usage?

node-fetch uses Node's http(s).Agent API and works with https-proxy-agent. Native fetch is built on Undici and ignores { agent }, so you need ProxyAgent or EnvHttpProxyAgent from Undici, or NODE_USE_ENV_PROXY=1 on Node 22.21+ or 24.0+.

How do I rotate proxies with node-fetch?

Two ways. Maintain a list of proxies and pick one per request (random or round-robin), creating a new HttpsProxyAgent each time. Or use a provider gateway that rotates server-side, so your code always points at the same endpoint.

Can I use SOCKS5 proxies with node-fetch?

Yes. Install socks-proxy-agent instead of https-proxy-agent and pass a socks5:// URL. The pattern is otherwise identical.

Why do I get TLS certificate errors when using a proxy with node-fetch?

Usually, because a corporate MITM proxy is re-signing TLS traffic with a CA your machine doesn't trust, or you're using a self-signed certificate. Load the correct CA via NODE_EXTRA_CA_CERTS or the { ca } option on the agent. Don't disable verification with NODE_TLS_REJECT_UNAUTHORIZED=0 in production.

HTTPX vs. Requests vs. AIOHTTP: How to Choose the Right Python HTTP Client

Requests, HTTPX, and AIOHTTP all make HTTP requests, but they differ in how they handle concurrency. Requests is synchronous and has been the default since 2011. HTTPX gives you both sync and async with HTTP/2 support. AIOHTTP is async-only and faster at high concurrency, but has a steeper learning curve. The right choice depends on your async model, whether you need WebSockets or HTTP/2, and how much code you're willing to rewrite. This article covers architecture, performance data, proxy setup, migration paths, and common mistakes in production scraping setups.

JavaScript Web Scraping Tutorial (2026)

Ever wished you could make the web work for you? JavaScript web scraping allows you to gather valuable information from websites in an automated way, unlocking insights that would be difficult to collect manually. In this guide, you'll learn the key tools, techniques, and best practices to scrape data efficiently, whether you're a beginner or a developer looking to streamline data collection.

Node-unblocker-hero

Node Unblocker: A Comprehensive Guide

Imagine this scenario: you’re bored at work with nothing to do. You decide to check out Reddit for a few minutes, but oh no, your network admin has blocked the website! Restrictions like that can be very annoying. Thankfully, Node Unblocker is an easy solution to overcoming any imposed limitations.

© 2018-2026 decodo.com (formerly smartproxy.com). All Rights Reserved