Axios vs Fetch vs Undici: Node.js HTTP Clients Compared
Why undici is the sensible default for Node.js server-to-server calls, and when Axios, native fetch, or Effect is the better pick
Picking an HTTP client for Node.js server-to-server calls is rarely a one-time decision: native fetch, Axios, undici, and Effect each behave differently under connection pressure, retry storms, and partial failures. For a service that spends its day calling other services, undici is the default worth reaching for. It owns its connection pool, its timeouts are explicit, and it is the same engine native fetch already runs on.
The failure that costs the most is also the easiest to ship: native fetch with no timeout. Hanging connections hold Lambda concurrent executions open, so a slow upstream becomes a bill and an outage at once. Connection pooling, timeout semantics, and circuit-breaker support decide how a client behaves on its worst day; feature lists do not.
Server-Side HTTP Client Trade-offs
In the browser, HTTP clients are straightforward. You make a request, handle the response, done. Server-side, four properties start to matter more than the API surface:
- Connection pooling becomes critical when you’re making thousands of requests per second
- Memory leaks can slowly kill your Node.js process over days
- Circuit breakers mean the difference between graceful degradation and cascading failures
- Retry strategies determine whether a network blip becomes an outage
Each client answers those four differently, and the differences only show up under load.
Native Fetch: Limits and Trade-offs
Since Node.js 18, we’ve had native fetch. It is tempting to use it everywhere: zero dependencies, a standard API, and no migration cost.
// Looks simple enough
const response = await fetch('https://api.example.com/data', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key: 'value' })
});
Where Native Fetch Shines
- Zero dependencies: Your docker images stay lean
- Standard API: Same code works in browser, Node.js, Deno, Bun
- Modern: Built on undici under the hood (since Node.js 18)
Where It Falls Short
Timeouts are where the abstraction gets thin:
// The timeout trap - this doesn't do what you think
const controller = new AbortController();
setTimeout(() => controller.abort(), 5000);
try {
const response = await fetch('https://slow-api.com', {
signal: controller.signal
});
} catch (error) {
// This catches the abort, but the TCP connection might still be open!
}
The AbortController only cancels the JavaScript side. The underlying TCP connection can stick around, slowly eating your connection pool. Undici-backed fetch manages this better than the old polyfills did, but under high concurrency you still need explicit timeout and connection-limit settings. Without them, process memory climbs for reasons no heap snapshot explains.
Production Verdict
Use native fetch for:
- Simple scripts and CLI tools
- Prototypes and POCs
- When you control both client and server
Avoid it when:
- You need retries, circuit breakers, or connection pooling
- Making thousands of requests per second
- Integrating with flaky third-party APIs
Axios: The Swiss Army Knife
Axios is still the most downloaded HTTP client on npm by a wide margin, and the ecosystem grown around it is the reason.
import axios from 'axios';
import axiosRetry from 'axios-retry';
// Production-ready configuration
const client = axios.create({
timeout: 10000,
maxRedirects: 5,
validateStatus: (status) => status < 500
});
// Add retry logic
axiosRetry(client, {
retries: 3,
retryDelay: axiosRetry.exponentialDelay,
retryCondition: (error) => {
return axiosRetry.isNetworkOrIdempotentRequestError(error) ||
error.response?.status === 429; // Rate limited
}
});
// Request/response interceptors for logging
client.interceptors.request.use((config) => {
config.headers['X-Request-ID'] = generateRequestId();
logger.info('Outgoing request', {
method: config.method,
url: config.url
});
return config;
});
Memory Leak Detection
Axios can leak memory when handling 502 errors, often due to issues in the follow-redirects dependency. Here’s how to identify this pattern:
// Memory leak reproduction
async function leakTest() {
const promises = [];
for (let i = 0; i < 10000; i++) {
promises.push(
axios.get('https://api.returns-502.com')
.catch(() => {}) // Error objects were retained in memory!
);
}
await Promise.all(promises);
// Check heap snapshot here - HTML error responses still in memory
}
Connection Pooling Fix
Plain Axios opens a new connection per request. At scale, this kills your server:
import Agent from 'agentkeepalive';
const keepAliveAgent = new Agent({
maxSockets: 100,
maxFreeSockets: 10,
timeout: 60000,
freeSocketTimeout: 30000
});
const client = axios.create({
httpAgent: keepAliveAgent,
httpsAgent: new Agent.HttpsAgent(keepAliveAgent.options)
});
Production Verdict
Axios is still solid for:
- Complex request/response transformations
- When you need extensive middleware
- Teams already familiar with it
But watch out for:
- Bundle size (1.84MB unpacked/unzipped, ~13KB gzipped for production bundles)
- Memory leaks with error responses
- Connection pooling requires extra setup. In corporate networks, proxy and certificate handling costs extra code as well.
Undici: The Performance Champion
Undici is what powers Node.js fetch internally. But using it directly gives you superpowers.
import { request, Agent } from 'undici';
const agent = new Agent({
connections: 100,
pipelining: 10, // HTTP/1.1 pipelining
keepAliveTimeout: 60 * 1000,
keepAliveMaxTimeout: 600 * 1000
});
// Reuses pooled sockets instead of opening one connection per call
const { statusCode, body } = await request('https://api.example.com', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ data: 'value' }),
dispatcher: agent
});
Measuring It on Your Own Workload
Client rankings move with payload size, keep-alive settings, TLS handshakes, and how many distinct hosts you talk to, so published numbers rarely survive contact with a specific service. Run the comparison against your own upstream before you commit to a client:
import { performance } from 'node:perf_hooks';
import { Agent, request } from 'undici';
import axios from 'axios';
import got from 'got';
const testUrl = 'https://httpbin.org/json';
const concurrency = 100;
const totalRequests = 10000;
const undiciAgent = new Agent({ connections: 50, pipelining: 10 });
const axiosClient = axios.create({ timeout: 5000 });
async function benchmark(name: string, clientFn: () => Promise<unknown>) {
const start = performance.now();
let issued = 0;
let completed = 0;
let failed = 0;
// Fixed pool of workers, so concurrency is actually bounded
const worker = async () => {
while (issued < totalRequests) {
issued++;
try {
await clientFn();
completed++;
} catch {
failed++;
}
}
};
await Promise.all(Array.from({ length: concurrency }, worker));
const duration = performance.now() - start;
console.log(`${name}: ${Math.round(totalRequests / (duration / 1000))} req/s`);
console.log(` Completed: ${completed}, failed: ${failed}`);
console.log(` Duration: ${Math.round(duration)}ms`);
}
// The response body must be consumed, or undici keeps the socket busy
await benchmark('undici', async () => {
const { body } = await request(testUrl, { dispatcher: undiciAgent });
await body.dump();
});
await benchmark('axios', () => axiosClient.get(testUrl));
await benchmark('got', () => got(testUrl).text());
Watch memory and p99 alongside throughput. A client that wins on average latency and loses on p99 is usually starving its connection pool.
HTTP/2 Support
Undici has HTTP/2 support, but it needs to be explicitly enabled:
import { Agent, request } from 'undici';
// Create agent with HTTP/2 enabled
const h2Agent = new Agent({
allowH2: true, // Enable HTTP/2
connections: 50,
pipelining: 0 // Disable pipelining for HTTP/2
});
// Use with specific HTTP/2 endpoints
const response = await request('https://http2.example.com/api', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ data: 'value' }),
dispatcher: h2Agent
});
// Or with global dispatcher
import { setGlobalDispatcher } from 'undici';
setGlobalDispatcher(h2Agent);
// Now all fetch calls use HTTP/2 when available
const h2Response = await fetch('https://http2.example.com/data');
HTTP/2 pays off when many parallel requests hit the same host, because they share one connection instead of queueing for pooled sockets:
// Run both against your own endpoint before switching
const h1Agent = new Agent({ allowH2: false });
const multiplexedAgent = new Agent({ allowH2: true });
// The gap widens with concurrency against a single host and
// closes as you fan out across many hosts, where the pool wins anyway.
Advanced Configuration: Proxy and Certificates
Undici provides extensive proxy and certificate management for production environments:
import { ProxyAgent, Agent } from 'undici';
import { readFileSync } from 'fs';
// Proxy configuration with authentication.
// `token` becomes the Proxy-Authorization header verbatim.
const proxyAgent = new ProxyAgent({
uri: 'http://proxy.corporate.com:8080',
token: `Basic ${Buffer.from('username:password').toString('base64')}`,
requestTls: {
ca: readFileSync('./ca.pem'),
cert: readFileSync('./client-cert.pem'),
key: readFileSync('./client-key.pem'),
rejectUnauthorized: true
}
});
// Custom certificate handling for self-signed or internal CAs
const secureAgent = new Agent({
connect: {
ca: [
readFileSync('./root-ca.pem'),
readFileSync('./intermediate-ca.pem')
],
cert: readFileSync('./client-cert.pem'),
key: readFileSync('./client-key.pem'),
// Certificate pinning
checkServerIdentity: (hostname, cert) => {
const expectedFingerprint = 'AA:BB:CC:DD:EE:FF...';
const actualFingerprint = cert.fingerprint256;
if (actualFingerprint !== expectedFingerprint) {
throw new Error(`Certificate fingerprint mismatch for ${hostname}`);
}
},
servername: 'api.internal.company.com', // SNI
minVersion: 'TLSv1.3',
maxVersion: 'TLSv1.3'
}
});
// Usage with retry on certificate errors
async function secureRequest(url: string, options = {}) {
try {
return await request(url, {
...options,
dispatcher: secureAgent
});
} catch (error) {
if (error.code === 'UNABLE_TO_VERIFY_LEAF_SIGNATURE') {
console.error('Certificate verification failed:', error);
// Fallback logic or alert
}
throw error;
}
}
NTLM is the exception here. Its handshake binds to the TCP connection rather than to the request, so none of the clients compared here can complete it on their own. Route those calls through a local proxy bridge such as cntlm or px, and let the client speak plain Basic auth to the bridge.
Production Verdict
Undici excels at:
- High-throughput microservices
- When every millisecond counts
- Memory-constrained environments
Skip it if:
- Your team prefers higher-level abstractions
- You’re migrating from Axios (too different)
- You need an extensive middleware ecosystem
Effect: The Functional Powerhouse
Effect takes a completely different approach. Instead of promises, you get composable effects with built-in error handling.
import { Effect, Schedule, Duration } from 'effect';
import { HttpClient, HttpClientError } from '@effect/platform';
// Define your API client with automatic retries
const apiClient = HttpClient.HttpClient.pipe(
HttpClient.retry(
Schedule.exponential(Duration.seconds(1), 2).pipe(
Schedule.jittered,
Schedule.either(Schedule.recurs(3))
)
),
HttpClient.filterStatusOk
);
// Type-safe error handling
const fetchUser = (id: string) =>
Effect.gen(function* (_) {
const response = yield* _(
apiClient.get(`/users/${id}`),
Effect.catchTag('HttpClientError', (error) => {
if (error.response?.status === 404) {
return Effect.succeed({ found: false });
}
return Effect.fail(error);
})
);
return yield* _(response.json);
});
The Learning Curve
Effect asks for a real investment before it pays anything back. The syntax stays alien until generators and pipes click. The payoff arrives later: failures live in the type signature, so a whole class of runtime surprises turns into compile errors instead.
// Before Effect: Runtime surprises
async function riskyOperation() {
try {
const user = await fetchUser();
const orders = await fetchOrders(user.id); // Might fail
return processOrders(orders); // Might also fail
} catch (error) {
// Is it network? Auth? Business logic? Who knows!
logger.error('Something failed', error);
}
}
// With Effect: Errors are part of the type
const safeOperation = Effect.gen(function* (_) {
const user = yield* _(fetchUser);
const orders = yield* _(fetchOrders(user.id));
return yield* _(processOrders(orders));
}).pipe(
Effect.catchTags({
NetworkError: (e) => logAndRetry(e),
AuthError: (e) => refreshTokenAndRetry(e),
ValidationError: (e) => Effect.fail(new BadRequest(e))
})
);
Production Verdict
Effect is perfect for:
- Complex business logic with multiple failure modes
- Teams comfortable with functional programming
- When type safety is critical
Think twice if:
- Your team is new to FP concepts
- You need to onboard juniors quickly
- It’s a simple CRUD service
The Rest, Briefly
Got: The Node.js Specialist
import got from 'got';
const client = got.extend({
timeout: { request: 10000 },
retry: {
limit: 3,
methods: ['GET', 'PUT', 'DELETE'],
statusCodes: [408, 429, 500, 502, 503, 504],
errorCodes: ['ETIMEDOUT', 'ECONNRESET'],
calculateDelay: ({ attemptCount }) => attemptCount * 1000
},
hooks: {
beforeRetry: [(error, retryCount) => {
logger.warn(`Retry attempt ${retryCount}`, error.message);
}]
}
});
Great for Node.js-only projects. Pagination helpers, streaming via got.stream, and opt-in DNS caching come in the box.
Ky: The Lightweight Fetch Wrapper
import ky from 'ky';
const api = ky.create({
prefixUrl: 'https://api.example.com',
timeout: 10000,
retry: {
limit: 2,
methods: ['get', 'put', 'delete'],
statusCodes: [408, 429, 500, 502, 503, 504]
}
});
Perfect when you want fetch with batteries included but minimal overhead.
SuperAgent: Still Alive
import superagent from 'superagent';
superagent
.post('/api/users')
.send({ name: 'John' })
.retry(3, (err, res) => {
if (err) return true;
return res.status >= 500;
})
.end((err, res) => {
// Callback style still works
});
Plugin system is powerful, but Axios won the popularity contest.
Hono: The Edge Runtime Champion
import { Hono } from 'hono';
import { HTTPException } from 'hono/http-exception';
const app = new Hono();
// Built for edge environments like Cloudflare Workers
app.post('/proxy', async (c) => {
const { url, method = 'GET', headers, body } = await c.req.json();
try {
// Uses web standard fetch under the hood
const response = await fetch(url, {
method,
headers: {
...headers,
'User-Agent': 'Hono-Proxy/1.0'
},
body: method !== 'GET' ? JSON.stringify(body) : undefined,
signal: AbortSignal.timeout(10000) // 10s timeout
});
// Stream response for efficiency
return new Response(response.body, {
status: response.status,
headers: response.headers
});
} catch (error) {
throw new HTTPException(502, {
message: `Upstream error: ${error.message}`
});
}
});
export default app;
Perfect for Cloudflare Workers, Vercel Edge Functions, and other edge runtimes where bundle size and cold start time matter most.
Enterprise Environment: Proxies, Certificates, and Corporate Networks
Corporate networks add requirements that no client handles by default: an egress proxy with credentials, an internal CA, client certificates, and a list of hosts that must bypass the proxy entirely. Undici covers all four through dispatchers:
import { Agent, ProxyAgent } from 'undici';
import { readFileSync } from 'node:fs';
const proxyUrl =
process.env.HTTPS_PROXY ??
process.env.https_proxy ??
process.env.HTTP_PROXY ??
process.env.http_proxy;
// Internal traffic: corporate root CA plus a client certificate, no proxy
const directDispatcher = new Agent({
connections: 50,
connect: {
ca: readFileSync('./corporate-root-ca.pem'),
cert: readFileSync('./client-cert.pem'),
key: readFileSync('./client-key.pem')
}
});
// Direct egress: Node's own CA bundle, no client certificate
const publicDispatcher = new Agent({ connections: 50 });
// External traffic: everything leaves through the egress proxy when one is set
const proxyDispatcher = proxyUrl
? new ProxyAgent({ uri: proxyUrl })
: publicDispatcher;
const INTERNAL_SUFFIXES = ['.internal.company.com', '.corp.company.com'];
const INTERNAL_PREFIXES = ['10.', '192.168.', '127.'];
function dispatcherFor(url: string) {
const { hostname } = new URL(url);
const isInternal =
hostname === 'localhost' ||
INTERNAL_SUFFIXES.some((suffix) => hostname.endsWith(suffix)) ||
INTERNAL_PREFIXES.some((prefix) => hostname.startsWith(prefix));
return isInternal ? directDispatcher : proxyDispatcher;
}
export function enterpriseFetch(url: string, options: RequestInit = {}) {
// Native fetch takes `dispatcher`; the old `agent` option is silently ignored
return fetch(url, { ...options, dispatcher: dispatcherFor(url) } as RequestInit);
}
Public hosts need their own dispatcher. An explicit connect.ca replaces Node’s bundled root store instead of adding to it. Reuse the internal dispatcher for direct egress and every public endpoint fails certificate validation. The client certificate also goes out to any server that asks for one.
The agent option is the trap. Node’s native fetch ignores it without warning, so a proxy that “works” in tests because the machine sits inside the network fails the moment the code runs in a container that has no direct egress.
Corporate Proxy Debugging
Common “connection refused” errors in enterprise environments often stem from:
- Corporate proxy requiring NTLM authentication
- Proxy configuration varying between environments
- Internal APIs being incorrectly routed through the proxy
- Proxy stripping certain headers
Items two and three are what dispatcherFor above removes: the bypass list lives in code instead of in a NO_PROXY string each environment spells differently. Match on the parsed hostname rather than on a substring of the URL, or a public host named api-10.example.com will quietly match a 10. private-range rule and skip the proxy.
Circuit Breakers: Your Production Lifesaver
No matter which HTTP client you choose, add a circuit breaker. Cockatiel keeps the wiring small:
import {
circuitBreaker,
retry,
wrap,
handleAll,
handleWhen,
BrokenCircuitError,
ConsecutiveBreaker,
ExponentialBackoff,
CircuitState
} from 'cockatiel';
import { request } from 'undici';
// Opens after 5 consecutive failures, probes the upstream again after 10s
const breaker = circuitBreaker(handleAll, {
halfOpenAfter: 10_000,
breaker: new ConsecutiveBreaker(5)
});
// The open circuit's rejection is not a retryable failure
const retryPolicy = retry(
handleWhen((error) => !(error instanceof BrokenCircuitError)),
{ maxAttempts: 3, backoff: new ExponentialBackoff() }
);
// Retry sits outside the breaker, so retries stop once the circuit opens
const resilient = wrap(retryPolicy, breaker);
async function fetchWithFallback(url: string) {
try {
return await resilient.execute(async () => {
const response = await request(url);
if (response.statusCode >= 500) {
throw new Error(`Server error: ${response.statusCode}`);
}
return response;
});
} catch (error) {
if (breaker.state === CircuitState.Open) {
return getCachedData();
}
throw error;
}
}
Order matters in wrap: the leftmost policy runs outermost. Putting the breaker outside the retry would let a single logical call burn three attempts against an upstream the breaker already knows is down. The retry filter carries the other half of the promise. Under plain handleAll, the open circuit’s rejection counts as a retryable failure, so every call still walks the full backoff schedule before the fallback gets its turn.
What a Breaker Actually Buys
When a payment provider starts timing out intermittently, every checkout request queues behind it and the whole flow stalls. A breaker converts that into a fast failure once the threshold trips, which is what makes a fallback provider or a cached response viable at all. Without it, the fallback never runs because nothing ever returns.
Production Monitoring Setup
Whatever client you choose, instrument it:
import { metrics, trace, SpanStatusCode } from '@opentelemetry/api';
const meter = metrics.getMeter('http-client');
const tracer = trace.getTracer('http-client');
const requestDuration = meter.createHistogram('http.client.duration', {
description: 'HTTP client request duration in milliseconds'
});
const activeRequests = meter.createUpDownCounter('http.client.active_requests', {
description: 'In-flight HTTP client requests'
});
export async function instrumentedRequest(url: string, options: RequestInit = {}) {
const method = options.method ?? 'GET';
const { hostname } = new URL(url);
const attributes: Record<string, string | number> = { method, hostname };
const span = tracer.startSpan(`HTTP ${method}`, { attributes });
const start = Date.now();
activeRequests.add(1, { method, hostname });
try {
const response = await fetch(url, {
...options,
headers: { ...options.headers, 'X-Trace-ID': span.spanContext().traceId }
});
attributes.status_code = response.status;
span.setAttribute('http.response.status_code', response.status);
return response;
} catch (error) {
attributes.error_type = (error as NodeJS.ErrnoException).code ?? 'unknown';
span.recordException(error as Error);
span.setStatus({ code: SpanStatusCode.ERROR });
throw error;
} finally {
requestDuration.record(Date.now() - start, attributes);
activeRequests.add(-1, { method, hostname });
span.end();
}
}
Record the duration in a histogram rather than a gauge, and read it as p95 and p99. Averages hide connection-pool starvation almost perfectly: a pool that serves 95 percent of calls instantly and parks the rest for seconds still posts a healthy mean. The in-flight counter is the other half of the picture, because it goes up and stays up when sockets stop being returned to the pool.
The Decision Matrix
A recommendation matrix based on use case and team context:
| Use Case | First Choice | Second Choice | Avoid |
|---|---|---|---|
| High-throughput microservices | Undici | Got | Native Fetch |
| Complex enterprise APIs | Axios | Effect | Ky |
| Functional programming team | Effect | - | SuperAgent |
| Simple scripts/CLIs | Native Fetch | Ky | Effect |
| Browser + Node.js | Axios | Ky | Undici |
| Edge computing (Cloudflare) | Native Fetch | Hono | Node-specific |
| Legacy system integration | Axios | SuperAgent | Effect |
Production Debugging: Practical Fixes
Phantom Memory Leaks
Services can slowly consume memory over days without obvious signs in heap dumps. A common cause is subtle bugs in error handling:
// The memory leak - can you spot it?
const pendingRequests = new Map();
async function makeRequest(id: string, url: string) {
const controller = new AbortController();
pendingRequests.set(id, controller);
try {
const response = await fetch(url, {
signal: controller.signal
});
return response;
} catch (error) {
// BUG: We never clean up successful or aborted requests!
if (error.name === 'AbortError') {
throw error;
}
throw error;
} finally {
// This should have been here all along
pendingRequests.delete(id);
}
}
Lesson: Always clean up request tracking, even in error paths.
Connection Pool Exhaustion
High-traffic events can expose connection pool limitations when services start returning 502s. The issue often traces to default connection limits:
import { Agent as HttpAgent } from 'node:http';
// Before: death by a thousand connections
const badClient = axios.create(); // Default agent, no socket limits
// After: controlled connection usage
const httpAgent = new HttpAgent({
keepAlive: true,
keepAliveMsecs: 30_000,
maxSockets: 20, // Per host
maxTotalSockets: 100, // Across all hosts
timeout: 60_000
});
const goodClient = axios.create({ httpAgent, timeout: 10_000 });
// Socket counts come off the agent, not off the environment
const countSockets = (pool: NodeJS.ReadOnlyDict<unknown[]>) =>
Object.values(pool).reduce((total, sockets) => total + (sockets?.length ?? 0), 0);
setInterval(() => {
console.warn(
`sockets in use: ${countSockets(httpAgent.sockets)}, ` +
`idle: ${countSockets(httpAgent.freeSockets)}`
);
}, 10_000).unref();
Analyzing Slow Requests
A request analyzer like the following removes most of the guesswork around slow calls:
class RequestAnalyzer {
private static slowRequests = new Map();
static trackRequest(url: string, options: RequestInit) {
const requestId = Math.random().toString(36);
const start = Date.now();
// Track the request stack trace for slow requests
const stack = new Error().stack;
this.slowRequests.set(requestId, {
url,
method: options.method || 'GET',
start,
stack: stack?.split('\n').slice(2, 8).join('\n') // Get caller context
});
// Auto cleanup after 30 seconds
setTimeout(() => {
const req = this.slowRequests.get(requestId);
if (req) {
const duration = Date.now() - req.start;
if (duration > 5000) {
console.warn(`Slow request detected after cleanup:`, {
...req,
duration,
possibleHang: duration > 30000
});
}
this.slowRequests.delete(requestId);
}
}, 30000);
return requestId;
}
static completeRequest(requestId: string, response?: Response, error?: Error) {
const req = this.slowRequests.get(requestId);
if (!req) return;
const duration = Date.now() - req.start;
if (duration > 1000) { // Log requests over 1s
console.warn(`Slow request completed:`, {
...req,
duration,
status: response?.status,
error: error?.message,
// This helps identify which part of your code made the slow request
callerStack: req.stack
});
}
this.slowRequests.delete(requestId);
}
}
// Usage with any HTTP client
async function trackedFetch(url: string, options: RequestInit = {}) {
const requestId = RequestAnalyzer.trackRequest(url, options);
try {
const response = await fetch(url, options);
RequestAnalyzer.completeRequest(requestId, response);
return response;
} catch (error) {
RequestAnalyzer.completeRequest(requestId, undefined, error as Error);
throw error;
}
}
Choosing Your Default
Default to undici for server-to-server traffic. You get a connection pool you own, timeouts that mean what they say, and the smallest gap between the configuration you write and what the socket actually does. Override it when the calling code needs Axios interceptors or shares a codebase with the browser, when the team already runs on Effect and wants HTTP inside the same typed error channel as everything else, or when the service makes a handful of calls a day and native fetch with AbortSignal.timeout is honestly enough.
Two settings survive whichever client wins. Timeouts belong in layers, with separate values for connect, per-request, and total elapsed time. Defaults belong under review, because most clients ship with limits tuned for scripts rather than for a service holding thousands of sockets open. Add the circuit breaker while the upstream is still healthy; wiring one in mid-incident is a much worse experience.
References
- Node.js HTTP Module Documentation - Official Node.js docs for the built-in http module, including agent socket limits and the client request API
- undici - GitHub - Official Node.js HTTP/1.1 client written from scratch and the foundation for native fetch; documents the Agent, ProxyAgent, and dispatcher APIs
- axios Documentation - Official axios documentation covering interceptors, instance configuration, and error handling
- got - GitHub - Human-friendly HTTP request library for Node.js with support for retries, streams, and hooks
- node-fetch - GitHub - Light-weight module bringing the Fetch API to Node.js, bridging browser and server HTTP patterns
- Node.js Fetch with Undici - Official guide to using the native fetch API backed by undici in modern Node.js
- Cockatiel - GitHub - Official docs for the circuit breaker, retry, and bulkhead policies and how
wrapcomposes them - OpenTelemetry JavaScript API - Official instrumentation guide for creating histograms, counters, and spans
Related posts
Lessons from running LangChain in production: the anti-patterns that cause failures, the patterns that work, with code examples and cost optimization strategies.
How pnpm catalogs stop dependency drift in JavaScript monorepos: one place to declare shared versions, named catalogs for legacy packages, and CI enforcement.
When a Node.js to Go move on AWS Lambda pays for itself and when it does not: the decision framework, the serverless Go patterns, and the cost math behind the call.
Why time bugs hide in production, how to migrate from Moment.js to Day.js or date-fns, and how to keep UTC everywhere with conversion only at the display boundary.
Build the redirect engine, analytics collection, and API Gateway config: performance optimizations and debugging strategies for millions of daily redirects.