< Back

Axios vs Fetch for Proxy Enabled HTTP Requests in Node.js: A Developer's Comparison for Production Scraping Stacks

Tech

A scraper works perfectly on a laptop. It ships to a container, the proxy credentials get injected as environment variables, and suddenly every request goes out on the host IP. Nothing throws. Nothing warns. The target sees a single datacenter address hammering it for six hours, then the blocks start.

That failure mode is not exotic. It is the direct result of Node.js having two completely separate HTTP stacks, only one of which pays attention to the proxy configuration most developers assume is global. Axios sits on one stack, native fetch sits on the other, and the difference matters far more once a proxy is in the path than it does for a simple API call.

This is a comparison for people running production data collection: how each client handles proxy tunnelling, authentication, session control, retries, timeouts, and throughput, and which one you should reach for depending on what your pipeline actually does.

Two HTTP Stacks, Not One

Axios in Node uses the built-in http and https core modules. Requests flow through http.Agent, socket pooling is agent-based, and anything that wants to intercept the connection does so by supplying a custom agent.

Native fetch, available globally since Node 18, is a completely different implementation. It is built on undici, which speaks HTTP directly over sockets and manages connections through dispatchers (Agent, Pool, Client, ProxyAgent) rather than http.Agent. It shares almost nothing with the legacy core module path.

The practical consequence is the one that bites teams in production: an http.Agent cannot be passed to fetch, and an undici dispatcher cannot be passed to Axios. Any abstraction layer you write that toggles between the two has to configure proxies twice, in two different shapes.

It also explains the silent failure at the top of this article. Historically, neither stack read HTTP_PROXY and HTTPS_PROXY on its own. Libraries and frameworks implemented that convention themselves. Recent Node releases have introduced experimental built-in support for environment proxy variables behind an opt-in flag, which is a welcome direction, but you should never assume it is active in whatever runtime your job lands on. Configure the proxy explicitly in code and you remove an entire class of outage.

Axios in a Proxy Enabled Stack

Proxy configuration and the agent trap

Axios ships a proxy option that looks like the obvious answer:

const res = await axios.get(url, {
proxy: {
protocol: 'http',
host: 'gateway.example.net',
port: 8080,
auth: { username: 'user-session-abc123', password: 'secret' }
}
});

For plain HTTP targets this is fine. For HTTPS targets through an HTTP proxy, which is almost everything you scrape, the battle-tested pattern in production codebases is to bypass the built-in handling and supply a tunnelling agent instead:

import { HttpsProxyAgent } from 'https-proxy-agent';

const agent = new HttpsProxyAgent('http://user-session-abc123:[email protected]:8080');

const res = await axios.get(url, {
httpAgent: agent,
httpsAgent: agent,
proxy: false
});

Setting proxy: false is not optional decoration. It tells Axios not to apply its own proxy logic on top of the agent, which is where a lot of confusing double-handling bugs come from.

The subtler trap is agent lifecycle. Creating a fresh HttpsProxyAgent per request is the single most common performance mistake in proxy-enabled Axios code. Every new agent means a new TCP connection, a new CONNECT exchange with the proxy, and a new TLS handshake with the origin. On a rotating gateway that is sometimes what you want, because a new tunnel can mean a new exit IP. On sticky sessions it is pure waste, and it can triple your effective latency.

The fix is an agent cache keyed by proxy URL:

const agents = new Map();

function agentFor(proxyUrl) {
if (!agents.has(proxyUrl)) {
agents.set(proxyUrl, new HttpsProxyAgent(proxyUrl, { keepAlive: true }));
}
return agents.get(proxyUrl);
}

With a pool of a few hundred sticky sessions, that map becomes your connection pool. Bound its size and evict idle entries, otherwise long-running workers leak sockets.

Interceptors, retries, and observability

This is where Axios earns its place. Request and response interceptors give you one clean place to attach a proxy, stamp a correlation ID, record latency, classify a block, and rotate on failure.

client.interceptors.response.use(null, async (error) => {
const cfg = error.config;
const status = error.response?.status;
if (status === 403 || status === 429 || error.code === 'ECONNRESET') {
cfg.__retries = (cfg.__retries || 0) + 1;
if (cfg.__retries <= 3) {
const next = pool.nextProxy();
cfg.httpsAgent = agentFor(next);
cfg.httpAgent = cfg.httpsAgent;
return client(cfg);
}
}
throw error;
});

The request config object travels with the retry, so state like attempt counts and the proxy actually used stays attached to the request rather than living in a side channel. When you are debugging why a specific subnet gets 403s on one target and not another, that traceability is worth real money.

Axios also throws on non-2xx by default. For scraping that is usually the behaviour you want: a 429 is an error condition in your pipeline, not a successful response you have to remember to inspect.

Streaming and large responses

Axios returns Node streams with responseType: 'stream', which slots directly into pipeline(), gzip decoders, and file writers. If your job downloads sitemaps, product feeds, images, or multi-megabyte JSON dumps through metered residential bandwidth, streaming to disk instead of buffering in memory is the difference between a stable worker and an out-of-memory crash at hour three.

Native Fetch and undici

Dispatchers instead of agents

Fetch has no proxy option and no agent option. Proxies are configured through a dispatcher:

import { ProxyAgent } from 'undici';

const dispatcher = new ProxyAgent({
uri: 'http://gateway.example.net:8080',
token: 'Basic ' + Buffer.from('user-session-abc123:secret').toString('base64')
});

const res = await fetch(url, { dispatcher });

The dispatcher option on fetch is a Node extension rather than part of the web standard, which is worth knowing if you share code with browser or edge runtimes. You can also call setGlobalDispatcher() to route everything, though in a scraping stack a global dispatcher is usually the wrong choice: you want per-request control so different jobs can ride different exit nodes.

undici's pooling is the strongest argument for this path. A ProxyAgent maintains persistent connections to the proxy and reuses tunnels per origin, with configurable connection counts, keep-alive timeouts, and pipelining. On a high-concurrency crawl against a handful of hosts, that pooling behaviour is measurably leaner than stacking hundreds of http.Agent instances.

The gap is SOCKS. undici has no native SOCKS5 support, so if your provider or your tooling requires SOCKS you either stay on the Axios and socks-proxy-agent path or write a custom connect function that hands undici a pre-established socket. That is perfectly doable, but it is code you now own and maintain.

Error semantics you have to work around

Fetch resolves on 404, 429, and 503. It only rejects on transport failures. Every scraper built on fetch therefore needs an explicit status check, and teams forget:

const res = await fetch(url, { dispatcher });
if (!res.ok) {
throw new HttpError(res.status, await res.text());
}

Miss that and your parser receives a Cloudflare interstitial, extracts zero fields, and reports success. Silent data loss is worse than a loud crash because nobody investigates a green dashboard.

undici's error codes are also their own dialect. UND_ERR_CONNECT_TIMEOUT, UND_ERR_HEADERS_TIMEOUT, and UND_ERR_SOCKET need mapping into whatever taxonomy your retry logic uses. Build that mapping once, centrally, or you will end up with inconsistent retry behaviour scattered across modules.

Timeouts and connection control

Fetch has no timeout option. You use AbortSignal.timeout(ms), which gives you a hard wall-clock ceiling on the whole request including body download. That is genuinely better than Axios's timeout, which behaves as an inactivity timeout and will happily let a slow trickle of bytes run for minutes.

undici goes further with separate headersTimeout and bodyTimeout on the dispatcher. On a proxy network, those two values tell you different things: a headers timeout usually means the exit node is struggling or the target is stalling the connection, while a body timeout points at bandwidth. Splitting them makes your telemetry much easier to read.

Proxy Specific Differences That Actually Bite

Credential encoding. Rotating gateways encode session control into the username: country, session ID, sticky duration, sometimes ASN targeting. Those strings contain colons, dashes, and underscores. If you embed credentials in a proxy URL, percent-encode them. A raw colon inside a username silently truncates the parse and you get a 407 that looks like a billing problem rather than a string problem.

Proxy-Authorization handling. Axios with a proxy agent sets the header during CONNECT automatically. With undici you can pass token explicitly, which is clearer but easier to get wrong on the first attempt. Either way, log 407 responses separately from 403s. They mean opposite things: one is your infrastructure, the other is the target.

Sticky sessions. If a target requires a consistent IP across login, cart, and checkout, the session lives in the proxy username, and your client has to keep sending the same credentials across requests. With Axios that means reusing the cached agent. With undici that means reusing the same ProxyAgent instance. Creating a new one mid-flow silently starts a new session and breaks the very continuity you were paying for.

DNS resolution. When you tunnel through an HTTP proxy with CONNECT, the hostname is resolved on the proxy side, which is what you want for geo consistency. Some SOCKS configurations resolve locally instead, leaking your real resolver and producing mismatched geography between DNS and exit IP. That mismatch is a detectable signal.

Cookies. Neither client ships a cookie jar for Node. Axios pairs with tough-cookie through a support package, undici offers cookie helpers and interceptor patterns. Whichever you pick, scope the jar per proxy session, never globally. A shared jar across rotating IPs is one of the fastest ways to get a session flagged.

Detection: What Neither Library Gives You

Be honest about what an HTTP client can and cannot do. Neither Axios nor fetch produces a browser TLS fingerprint. Both use Node's OpenSSL configuration, so the JA3 and JA4 hashes they present are recognisably Node, not Chrome. On a target running serious behavioural fingerprinting, header ordering and TLS characteristics can flag you regardless of how clean the exit IP is.

There is a subtle difference worth knowing. Fetch normalises header names to lowercase per the web standard, so over HTTP/1.1 it emits lowercase headers, which is not what a real browser does on that protocol version. Axios preserves the casing you give it. If you are hand-crafting a header set to match a browser profile, Axios gives you more control over the surface, though neither closes the TLS gap.

The strategic takeaway: match the client to the target. For JSON APIs, partner feeds, sitemaps, and lightly protected HTML, a fast HTTP client on a good proxy pool is the right tool and the cheapest by an order of magnitude. For targets fronted by advanced bot management, no amount of Axios configuration substitutes for a fingerprint-consistent browser or a TLS-impersonating client. Route intelligently instead of forcing one client to do everything.

Throughput and Concurrency at Scale

Raw benchmarks published for these libraries rarely reflect proxy reality, because the proxy, not the client, is usually the bottleneck. Still, architecture matters at high volume.

undici generally wins on per-request overhead. It allocates less, parses faster, and its pooling model is designed for exactly this workload. Axios adds a transform and interceptor layer on top of core modules, which costs a little CPU per request. At a few dozen requests per second nobody notices. At several thousand, with hundreds of concurrent sockets per worker, it shows up in flame graphs.

The bigger lever is connection reuse. Modern Node enables keep-alive on the global agent by default, but custom agents inherit whatever you configure, and proxy agents in particular need explicit attention. Every avoided CONNECT plus TLS handshake saves a full round trip through the proxy, which on a residential exit halfway around the world can be 300 milliseconds or more.

Cap concurrency per exit node, not just per job. Thirty parallel requests through one residential IP is a behavioural signal no rotation strategy can hide. Both clients let you do this; neither does it for you.

Where Proxies Fit In

The client library is the last mile. The thing that decides whether your requests succeed is the network behind it: the pool type, the geography, the sourcing, and the session controls exposed to your code.

That is why the credential format matters so much to application design. Session control expressed through the username means your Axios agent cache or your undici ProxyAgent instance becomes the unit of identity in your system. Pool diversity matters just as much: a static datacenter range will get you through a public API or a sitemap at very low cost, while consumer-facing pages with regional pricing typically need ethically sourced proxy pools with real residential or mobile exits. Most production stacks run several pool types side by side and route by target, because paying residential rates for a robots.txt fetch is wasted budget.

This is the layer EnigmaProxy operates in, with residential, ISP, datacenter, and mobile pools available through a consistent authentication model and broad geo-coverage. Predictable, tiered pricing across pool types makes the routing decision above an actual engineering choice rather than a guess, and it is worth mapping your expected request mix against plan costs before you finalise the architecture.

One practical habit for either client: validate credentials outside your application before you debug your application. When a request returns 407 or resolves to an unexpected country, confirming the endpoint independently with a proxy testing tool takes thirty seconds and tells you immediately whether the fault sits in your dispatcher configuration or upstream. Plenty of engineering hours have been lost rewriting agent code to fix a mistyped password.

Native proxy support is arriving in the runtime. Node has begun shipping experimental support for reading proxy environment variables into both the core HTTP modules and global fetch. Once that stabilises, a meaningful chunk of proxy plumbing code disappears. Design your configuration layer so proxy selection is one injectable function, not logic sprinkled across twenty call sites, and you will absorb that change in an afternoon.

undici is becoming the default assumption. New tooling increasingly targets fetch and dispatchers rather than http.Agent. Axios is not going anywhere and remains excellent, but greenfield services written today more often start on fetch, and the ecosystem of dispatcher-based middleware is growing faster than the agent ecosystem.

Protocol drift will widen the fingerprint gap. As more targets move to HTTP/2 and HTTP/3 and treat protocol behaviour as a signal, plain HTTP clients will become easier to distinguish from browsers. Expect hybrid stacks to be normal: a fast client for the bulk of traffic, a fingerprint-faithful client or a real browser for the hard 10 percent, with both drawing from the same proxy pool and the same session manager.

Observability becomes a procurement question. Teams are starting to track success rate and latency per pool, per country, and per target, and to make routing decisions from that data. Whichever client you choose, emit the proxy identifier, the pool type, the status class, and the timing on every request. Without that, comparing providers or pool types is guesswork.

Conclusion

Choose Axios when you want ergonomics and control: interceptor-based retry and rotation, Node stream responses, mature SOCKS5 support, familiar error semantics, and fine-grained header control. It remains the pragmatic default for scraping workers where developer clarity beats microseconds.

Choose native fetch with undici when throughput and footprint dominate: efficient connection pooling, headersTimeout and bodyTimeout separation, hard abort signals, no dependency to audit, and code that travels to edge runtimes. Accept that you will write a thin wrapper for status checks, retries, and error mapping, and write it once.

What neither choice fixes is the network underneath. Agent caches, dispatcher reuse, per-IP concurrency caps, and correct credential encoding all exist to serve the proxy layer, and a well-structured client on a weak pool still gets blocked. Pair a deliberate client architecture with business-grade, ethically sourced infrastructure from a provider such as EnigmaProxy, and the difference between a scraper that survives a quarter and one that dies in a week usually comes down to those fundamentals rather than the library on the import line.