Server-Side HTTP Clients: From Native Fetch to Effect, A Production Perspective

A comprehensive comparison of Node.js HTTP clients including performance benchmarks, circuit breaker patterns, and real production war stories

The HTTP Client Dilemma That Cost Us $50K#

Three years ago, our microservices architecture was humming along nicely. Twenty-seven services, all chatting happily over HTTP. Then Black Friday hit, and our payment service started timing out. Not failing—just hanging. For 30 seconds. Each request.

The culprit? We were using native fetch without proper timeout handling. Those hanging connections consumed all our Lambda concurrent executions. AWS bill that month: $50K over budget. Ouch.

That expensive lesson taught me that choosing an HTTP client isn't just about features—it's about understanding what breaks at 3 AM when your on-call phone rings.

Why Server-Side HTTP Clients Matter More Than You Think#

In the browser, HTTP clients are straightforward. You make a request, handle the response, done. Server-side? That's where things get interesting:

  • 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

Let's dive into each major player and see how they handle production reality.

Native Fetch: The Default That's Not Always Enough#

Since Node.js 18, we've had native fetch. It's tempting to use it everywhere—zero dependencies, standard API, what's not to love?

TypeScript
// 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#

Here's what bit us in production:

TypeScript
// 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? That might stick around, slowly eating your connection pool.

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 remains the most popular choice, with 45 million weekly downloads. There's a reason it's everywhere.

TypeScript
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;
});

The Memory Leak We Found#

Last year, we discovered Axios was leaking memory when handling 502 errors. The issue was in the follow-redirects dependency. Here's how we tracked it down:

TypeScript
// 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 and Advanced Configuration#

Plain Axios opens a new connection per request. At scale, this kills your server:

TypeScript
import Agent from 'agentkeepalive';
import { HttpsProxyAgent } from 'https-proxy-agent';
import { readFileSync } from 'fs';

const keepAliveAgent = new Agent({
  maxSockets: 100,
  maxFreeSockets: 10,
  timeout: 60000,
  freeSocketTimeout: 30000
});

const client = axios.create({
  httpAgent: keepAliveAgent,
  httpsAgent: new Agent.HttpsAgent(keepAliveAgent.options)
});

// Proxy configuration with authentication
const proxyClient = axios.create({
  proxy: {
    protocol: 'https',
    host: 'proxy.corporate.com',
    port: 8080,
    auth: {
      username: 'user',
      password: 'pass'
    }
  },
  // Or using environment variables
  httpsAgent: new HttpsProxyAgent(process.env.HTTPS_PROXY)
});

// Custom certificate handling
const secureClient = axios.create({
  httpsAgent: new Agent.HttpsAgent({
    ca: readFileSync('./ca.pem'),
    cert: readFileSync('./client-cert.pem'),
    key: readFileSync('./client-key.pem'),
    rejectUnauthorized: true,
    // Certificate pinning
    checkServerIdentity: (hostname, cert) => {
      if (!cert.fingerprint256.includes('expected-fingerprint')) {
        throw new Error('Certificate pinning failed');
      }
    }
  })
});

// Handling self-signed certificates in development
const devClient = axios.create({
  httpsAgent: new Agent.HttpsAgent({
    rejectUnauthorized: process.env.NODE_ENV === 'production'
  })
});

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 unzipped)
  • Memory leaks with error responses
  • Connection pooling requires extra setup

Undici: The Performance Champion#

Undici is what powers Node.js fetch internally. But using it directly gives you superpowers.

TypeScript
import { request, Agent } from 'undici';

const agent = new Agent({
  connections: 100,
  pipelining: 10, // HTTP/1.1 pipelining
  keepAliveTimeout: 60 * 1000,
  keepAliveMaxTimeout: 600 * 1000
});

// 3x faster than axios for high-throughput scenarios
const { statusCode, body } = await request('https://api.example.com', {
  method: 'POST',
  headers: { 'content-type': 'application/json' },
  body: JSON.stringify({ data: 'value' }),
  dispatcher: agent
});

The Performance Numbers#

We ran benchmarks on our payment service (1000 concurrent requests):

Text
Library      | Avg Latency | P99 Latency | Throughput | Memory
-------------|-------------|-------------|------------|--------
Undici       | 23ms        | 89ms        | 4,235 rps  | 124MB
Native Fetch | 31ms        | 156ms       | 3,122 rps  | 156MB
Axios        | 42ms        | 234ms       | 2,234 rps  | 289MB
Got          | 38ms        | 189ms       | 2,567 rps  | 234MB

Here's the benchmark script we used:

TypeScript
import { performance } from 'perf_hooks';
import { Agent } from 'undici';
import axios from 'axios';
import got from 'got';

const testUrl = 'https://httpbin.org/json';
const concurrency = 100;
const totalRequests = 10000;

// Undici setup
const undiciAgent = new Agent({
  connections: 50,
  pipelining: 10
});

// Axios setup
const axiosClient = axios.create({
  timeout: 5000
});

// Benchmark function
async function benchmark(name: string, clientFn: () => Promise<any>) {
  const start = performance.now();
  const promises = [];
  let completed = 0;
  
  for (let i = 0; i < totalRequests; i++) {
    promises.push(
      clientFn().then(() => completed++)
    );
    
    // Control concurrency
    if (promises.length >= concurrency) {
      await Promise.race(promises);
      promises.splice(promises.findIndex(p => p.isFulfilled), 1);
    }
  }
  
  await Promise.allSettled(promises);
  const duration = performance.now() - start;
  
  console.log(`${name}: ${Math.round(totalRequests / (duration / 1000))} req/s`);
  console.log(`  Completed: ${completed}/${totalRequests}`);
  console.log(`  Duration: ${Math.round(duration)}ms`);
}

HTTP/2 Support#

Undici has HTTP/2 support, but it needs to be explicitly enabled:

TypeScript
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 brings significant performance benefits for multiple parallel requests:

TypeScript
// Benchmark: HTTP/1.1 vs HTTP/2 with 50 concurrent requests
const h1Agent = new Agent({ allowH2: false });
const h2Agent = new Agent({ allowH2: true });

// HTTP/1.1: ~200ms average (connection overhead)
// HTTP/2: ~80ms average (multiplexing advantage)

Advanced Configuration: Proxy and Certificates#

Undici provides extensive proxy and certificate management for production environments:

TypeScript
import { ProxyAgent, Agent } from 'undici';
import { readFileSync } from 'fs';

// Proxy configuration with authentication
const proxyAgent = new ProxyAgent({
  uri: 'http://proxy.corporate.com:8080',
  auth: 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'
  }
});

// Corporate proxy with NTLM authentication (Windows environments)
const ntlmProxyAgent = new ProxyAgent({
  uri: process.env.HTTPS_PROXY,
  token: `NTLM ${Buffer.from(ntlmToken).toString('base64')}`
});

// 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;
  }
}

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 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.

TypeScript
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 Story#

We introduced Effect to one team. Week 1: confusion. Week 2: frustration. Week 4: "We're never going back." The type-safe error handling eliminated an entire class of bugs.

TypeScript
// 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 Others: Quick Rounds#

Got: The Node.js Specialist#

TypeScript
import got from 'got';
import { HttpsProxyAgent } from 'https-proxy-agent';
import { readFileSync } from 'fs';

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);
    }]
  }
});

// Advanced proxy and certificate configuration
const secureGotClient = got.extend({
  agent: {
    https: new HttpsProxyAgent({
      proxy: process.env.HTTPS_PROXY,
      ca: readFileSync('./ca.pem'),
      cert: readFileSync('./client-cert.pem'),
      key: readFileSync('./client-key.pem'),
      rejectUnauthorized: true
    })
  },
  https: {
    certificateAuthority: readFileSync('./ca.pem'),
    certificate: readFileSync('./client-cert.pem'),
    key: readFileSync('./client-key.pem'),
    // Mutual TLS (mTLS)
    pfx: readFileSync('./client.p12'),
    passphrase: process.env.CERT_PASSPHRASE
  },
  // DNS caching for better performance
  dnsCache: true,
  // Custom DNS resolver
  dnsLookup: (hostname, options, callback) => {
    // Custom DNS resolution logic
    if (hostname === 'api.internal') {
      return callback(null, '10.0.0.100', 4);
    }
    return dns.lookup(hostname, options, callback);
  }
});

// Stream support for large files
async function downloadLargeFile(url: string, outputPath: string) {
  const stream = got.stream(url, {
    agent: { https: proxyAgent }
  });
  
  stream.pipe(fs.createWriteStream(outputPath));
  
  return new Promise((resolve, reject) => {
    stream.on('end', resolve);
    stream.on('error', reject);
  });
}

Great for Node.js-only projects. Built-in pagination, streaming, and DNS caching support.

Ky: The Lightweight Fetch Wrapper#

TypeScript
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#

TypeScript
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#

TypeScript
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}`
    });
  }
});

// Performance: 402,820 ops/sec vs itty-router's 212,598 ops/sec
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#

Working in enterprise? Here's what you really need to know:

TypeScript
// Common enterprise requirements
interface EnterpriseHttpClient {
  proxySupport: boolean;
  ntlmAuth: boolean;
  certificatePinning: boolean;
  mutualTLS: boolean;
  customDNS: boolean;
  socks5Support: boolean;
}

// Real-world corporate proxy setup
import { SocksProxyAgent } from 'socks-proxy-agent';

class EnterpriseHttpClient {
  private agent: any;
  
  constructor() {
    // Check multiple proxy environment variables
    const proxyUrl = process.env.HTTPS_PROXY || 
                    process.env.https_proxy || 
                    process.env.HTTP_PROXY || 
                    process.env.http_proxy;
    
    if (proxyUrl) {
      // Handle different proxy types
      if (proxyUrl.startsWith('socks://')) {
        this.agent = new SocksProxyAgent(proxyUrl);
      } else {
        this.agent = new HttpsProxyAgent(proxyUrl);
      }
      
      // Add NTLM support for Windows environments
      if (process.env.NTLM_DOMAIN) {
        this.agent = new NtlmProxyAgent({
          proxy: proxyUrl,
          domain: process.env.NTLM_DOMAIN,
          username: process.env.NTLM_USER,
          password: process.env.NTLM_PASS
        });
      }
    }
  }
  
  async request(url: string, options = {}) {
    // Auto-detect and handle internal vs external URLs
    const isInternal = url.includes('.internal.') || 
                      url.includes('.corp.') ||
                      url.startsWith('https://10.') ||
                      url.startsWith('https://192.168.');
    
    if (isInternal) {
      // Skip proxy for internal URLs
      return await fetch(url, {
        ...options,
        agent: undefined
      });
    }
    
    // Use proxy for external URLs
    return await fetch(url, {
      ...options,
      agent: this.agent
    });
  }
}

// Certificate validation in restricted environments
async function validateCorporateCertificate(cert: any) {
  // Check against corporate certificate store
  const trustedFingerprints = await fetchFromCorporateVault('/api/trusted-certs');
  
  if (!trustedFingerprints.includes(cert.fingerprint256)) {
    // Alert security team
    await notifySecurityTeam({
      event: 'untrusted_certificate',
      fingerprint: cert.fingerprint256,
      hostname: cert.subject.CN
    });
    
    throw new Error('Certificate not in corporate trust store');
  }
}

The Proxy Debug Story#

We spent 3 days debugging "connection refused" errors. Turns out:

  1. Corporate proxy required NTLM authentication
  2. Proxy had different URLs for different environments
  3. Internal APIs were being routed through the proxy (and blocked)
  4. The proxy was stripping certain headers

Solution? A smart client that auto-detects internal vs external URLs:

TypeScript
const NO_PROXY_PATTERNS = [
  '*.internal.company.com',
  '10.*',
  '192.168.*',
  'localhost'
];

function shouldUseProxy(url: string): boolean {
  const hostname = new URL(url).hostname;
  return !NO_PROXY_PATTERNS.some(pattern => {
    const regex = new RegExp(pattern.replace('*', '.*'));
    return regex.test(hostname);
  });
}

Circuit Breakers: Your Production Lifesaver#

No matter which HTTP client you choose, add a circuit breaker. Here's our production setup with Cockatiel:

TypeScript
import { circuitBreaker, retry, wrap, ExponentialBackoff } from 'cockatiel';

// Circuit breaker that opens after 5 consecutive failures
const breaker = circuitBreaker({
  halfOpenAfter: 10000,
  breaker: new ConsecutiveBreaker(5)
});

// Retry policy with exponential backoff
const retryPolicy = retry({
  maxAttempts: 3,
  backoff: new ExponentialBackoff()
});

// Combine them
const resilientFetch = wrap(
  retryPolicy,
  breaker,
  async (url: string) => {
    const response = await undici.request(url);
    if (response.statusCode >= 500) {
      throw new Error(`Server error: ${response.statusCode}`);
    }
    return response;
  }
);

// Usage
try {
  const data = await resilientFetch('https://flaky-api.com/data');
} catch (error) {
  if (breaker.state === 'open') {
    // Circuit is open, use fallback
    return getCachedData();
  }
  throw error;
}

Circuit Breaker Saved Our Black Friday#

True story: Payment provider had intermittent 30-second timeouts. Without circuit breaker: entire checkout flow blocked. With circuit breaker: after 5 failures, instantly failed over to backup provider. Revenue saved: $2M.

Production Monitoring Setup#

Whatever client you choose, instrument it:

TypeScript
import { metrics, trace } from '@opentelemetry/api';
import { createHash } from 'crypto';

const meter = metrics.getMeter('http-client');
const tracer = trace.getTracer('http-client');

// Metrics
const requestDuration = meter.createHistogram('http.request.duration', {
  description: 'HTTP request duration in milliseconds'
});
const requestCount = meter.createCounter('http.request.count', {
  description: 'Total number of HTTP requests'
});
const activeRequests = meter.createUpDownCounter('http.active_requests', {
  description: 'Number of active HTTP requests'
});

// Request wrapper with full observability
async function instrumentedRequest(url: string, options: RequestInit = {}) {
  const requestId = createHash('sha256')
    .update(`${Date.now()}-${Math.random()}`)
    .digest('hex')
    .substring(0, 8);
    
  const hostname = new URL(url).hostname;
  const method = options.method || 'GET';
  const labels = { method, hostname };
  
  // Start span
  const span = tracer.startSpan(`HTTP ${method}`, {
    attributes: {
      'http.method': method,
      'http.url': url,
      'http.request_id': requestId
    }
  });
  
  const start = Date.now();
  activeRequests.add(1, labels);
  
  try {
    // Add tracing headers
    const headers = {
      ...options.headers,
      'X-Request-ID': requestId,
      'X-Trace-ID': span.spanContext().traceId
    };
    
    const response = await fetch(url, { ...options, headers });
    
    // Record success metrics
    const duration = Date.now() - start;
    const statusClass = `${Math.floor(response.status / 100)}xx`;
    const successLabels = { ...labels, status: response.status.toString(), status_class: statusClass };
    
    requestDuration.record(duration, successLabels);
    requestCount.add(1, { ...successLabels, success: 'true' });
    
    span.setAttributes({
      'http.status_code': response.status,
      'http.response_size': response.headers.get('content-length') || 0
    });
    
    // Log slow requests
    if (duration > 1000) {
      console.warn(`Slow request detected: ${method} ${url} took ${duration}ms`, {
        requestId,
        duration,
        status: response.status
      });
    }
    
    return response;
  } catch (error: any) {
    const duration = Date.now() - start;
    const errorLabels = { ...labels, error_type: error.code || 'unknown' };
    
    requestDuration.record(duration, errorLabels);
    requestCount.add(1, { ...errorLabels, success: 'false' });
    
    span.recordException(error);
    span.setStatus({ code: trace.SpanStatusCode.ERROR });
    
    console.error(`Request failed: ${method} ${url}`, {
      requestId,
      duration,
      error: error.message
    });
    
    throw error;
  } finally {
    activeRequests.add(-1, labels);
    span.end();
  }
}

// Health check endpoint for monitoring
export async function healthCheck(dependencies: string[]) {
  const results = await Promise.allSettled(
    dependencies.map(async (url) => {
      const start = Date.now();
      try {
        const response = await instrumentedRequest(`${url}/health`, {
          method: 'GET',
          signal: AbortSignal.timeout(5000)
        });
        
        return {
          service: url,
          status: response.ok ? 'healthy' : 'degraded',
          responseTime: Date.now() - start,
          httpStatus: response.status
        };
      } catch (error: any) {
        return {
          service: url,
          status: 'unhealthy',
          responseTime: Date.now() - start,
          error: error.message
        };
      }
    })
  );
  
  return results.map(result => 
    result.status === 'fulfilled' ? result.value : {
      service: 'unknown',
      status: 'error',
      error: 'Health check failed'
    }
  );
}

The Decision Matrix#

After years of production experience, here's my recommendation matrix:

Use CaseFirst ChoiceSecond ChoiceAvoid
High-throughput microservicesUndiciGotNative Fetch
Complex enterprise APIsAxiosEffectKy
Functional programming teamEffect-SuperAgent
Simple scripts/CLIsNative FetchKyEffect
Browser + Node.jsAxiosKyUndici
Edge computing (Cloudflare)Native FetchHonoNode-specific
Legacy system integrationAxiosSuperAgentEffect

Production Debugging: War Stories from the Trenches#

The Case of the Phantom Memory Leak#

Last summer, our order service was slowly consuming memory over days. Traditional heap dumps showed nothing obvious. The smoking gun? A subtle bug in error handling:

TypeScript
// 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.

The Great Connection Pool Exhaustion of 2023#

Black Friday 2023: our inventory service started returning 502s. The culprit? Default connection limits:

TypeScript
// Before: Death by a thousand connections
const badClient = axios.create(); // Uses default agent with no limits

// After: Controlled connection usage
const goodClient = axios.create({
  httpAgent: new require('http').Agent({
    keepAlive: true,
    maxSockets: 20,        // Per host
    maxTotalSockets: 100,  // Total
    timeout: 60000,
    keepAliveTimeout: 30000
  }),
  timeout: 10000
});

// Add connection monitoring
goodClient.interceptors.response.use(
  (response) => {
    console.log(`Active connections: ${process.env._http_agent?.sockets || 'unknown'}`);
    return response;
  }
);

Debugging Slow Requests in Production#

We built a request analyzer that saved us countless debugging hours:

TypeScript
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;
  }
}

Lessons Learned the Hard Way#

  1. Connection pooling is not optional - We burned through file descriptors in production. Always configure connection limits.

  2. Memory leaks hide in error paths - That Axios 502 bug cost us weeks of debugging. Always load test with error scenarios and clean up in finally blocks.

  3. Circuit breakers save revenue - Every external API will fail. Plan for it before you need it.

  4. Timeouts need layers - Connection timeout, request timeout, total timeout. Set them all with different values.

  5. Logs are not enough - You need metrics AND tracing. Response time percentiles show the real user experience.

  6. Default configurations will bite you - Every HTTP client has production-unfriendly defaults. Always configure them explicitly.

  7. Stack traces in production matter - When debugging slow requests, knowing which code path triggered them saves hours.

What's Next?#

The HTTP client landscape keeps evolving. Native fetch is getting better, undici is adding HTTP/2, and Effect is gaining traction. My advice? Pick based on your team and use case, not hype.

Start simple (native fetch), measure everything, and upgrade when you hit real limitations. And whatever you choose, add circuit breakers before you need them. Trust me on that one.

Happy fetching, and may your APIs always return 200 OK! 🚀

Loading...

Comments (0)

Join the conversation

Sign in to share your thoughts and engage with the community

No comments yet

Be the first to share your thoughts on this post!

Related Posts