Introducing reixo — The TypeScript HTTP Client That Replaces axios in 2026

Sanjeev SharmaSanjeev Sharma
7 min read

Advertisement

Introduction

Why This Matters

Every production Node.js application eventually builds the same wrapper around fetch or axios: exponential backoff retries, timeout handling, normalized error types, maybe a circuit breaker. You rebuild this layer in every project. reixo is that layer — built once, typed thoroughly, and shipped as a single zero-dependency package.

What makes reixo different is its default error-handling model. Instead of throwing exceptions, every request returns a Result<T, E> discriminated union. You cannot accidentally ignore an error — the TypeScript type system enforces that you check result.ok before accessing result.data. This alone eliminates the most common class of HTTP client bugs in production code.

Installation

npm install reixo
# or
yarn add reixo
# or
pnpm add reixo

Quick Start

import { HTTPBuilder } from 'reixo'
 
const client = new HTTPBuilder()
  .withBaseURL('https://api.example.com')
  .withTimeout(10_000)
  .withHeader('Authorization', 'Bearer <your-token>')
  .build()
 
// Result&lt;T, E&gt; style — recommended, no try/catch needed
const result = await client.tryGet<User[]>('/users')
 
if (result.ok) {
  console.log(result.data.data)  // User[] — fully typed
} else {
  console.error(result.error.status)  // HTTPError.status
}
 
// Traditional throwing style — also supported
const response = await client.get<User[]>('/users')
console.log(response.data)

No-Throw Result<T, E> API

The tryGet, tryPost, tryPut, tryDelete methods return Ok | Err instead of throwing. You handle both cases at the call site:

// With axios — exception-based, easy to forget
try {
  const res = await axios.get<User[]>('/users')
  return res.data
} catch (err) {
  if (axios.isAxiosError(err)) {
    console.error(err.response?.status)  // might be undefined
  }
}
 
// With reixo — Result-based, exhaustive by the type system
const result = await client.tryGet<User[]>('/users')
if (!result.ok) {
  console.error(result.error.status)  // always defined, always typed
  return
}
return result.data.data  // TypeScript knows result.data exists here

Typed Error Classes

reixo gives you distinct error classes for each failure mode — no more checking err.response?.status in a catch block:

import {
  HTTPError,
  NetworkError,
  TimeoutError,
  AbortError,
  CircuitOpenError,
} from 'reixo'
 
try {
  await client.get('/api/data')
} catch (err) {
  if (err instanceof HTTPError) {
    console.error(`HTTP ${err.status}: ${err.statusText}`)
  } else if (err instanceof TimeoutError) {
    console.error(`Timed out after ${err.timeoutMs}ms`)
  } else if (err instanceof CircuitOpenError) {
    console.warn('Circuit breaker is open — using fallback data')
  } else if (err instanceof NetworkError) {
    console.error('Network failure:', err.message)
  }
}

Built-in Retry with Exponential Backoff

Configure retries once at the client level — no per-request boilerplate:

const client = new HTTPBuilder()
  .withBaseURL('https://api.example.com')
  .withRetry({
    maxRetries: 3,
    initialDelayMs: 200,
    backoffFactor: 2,       // 200ms, 400ms, 800ms
    jitter: true,           // Randomize delays to spread concurrent retries
    retryCondition: (err) => {
      if (err instanceof NetworkError) return true
      if (err instanceof HTTPError) return err.status &gt;= 500 || err.status === 429
      return false
    },
    onRetry: (err, attempt, delayMs) => {
      console.log(`Retry #${attempt} in ${delayMs}ms due to: ${err.message}`)
    },
  })
  .build()

Circuit Breaker

When a downstream service is down, continuing to send requests wastes resources and causes cascading failures. The circuit breaker opens after a configurable failure threshold and stops all requests immediately:

const client = new HTTPBuilder()
  .withCircuitBreaker({
    failureThreshold: 5,     // Open after 5 consecutive failures
    resetTimeoutMs: 30_000,  // Try to recover after 30 seconds
    onStateChange: (prev, next) => {
      console.log(`Circuit breaker: ${prev} → ${next}`)
      // Possible states: CLOSED, OPEN, HALF_OPEN
    },
  })
  .build()
 
// When OPEN, requests fail instantly with CircuitOpenError
// — no network call is made, protecting the downstream service
const result = await client.tryGet('/api/orders')
if (!result.ok && result.error instanceof CircuitOpenError) {
  return cachedOrders  // Fall back to stale data
}

Request Deduplication

When multiple components trigger the same GET request simultaneously (common on page load), deduplication collapses them into a single network call:

const client = new HTTPBuilder()
  .withBaseURL('https://api.example.com')
  .withDeduplication()
  .build()
 
// All 5 calls share one Promise — only 1 HTTP request is made
const [r1, r2, r3, r4, r5] = await Promise.all([
  client.get('/config'),
  client.get('/config'),
  client.get('/config'),
  client.get('/config'),
  client.get('/config'),
])

Deduplication keys on method + URL + query parameters. POST, PUT, and DELETE requests are never deduplicated because they have side effects.

LRU Caching

const client = new HTTPBuilder()
  .withCache({
    ttl: 120_000,                      // Cache entries live for 2 minutes
    strategy: 'stale-while-revalidate', // Return stale data, refetch in background
    storage: 'memory',
    maxEntries: 200,                   // LRU eviction after 200 entries
  })
  .build()
 
const res = await client.get('/config')
if (res.cacheMetadata?.hit) {
  console.log(`Cache hit — ${res.cacheMetadata.age}s old`)
}

OpenTelemetry Tracing — Zero Config

Injects W3C traceparent headers on every outgoing request with no @opentelemetry/* dependencies:

const client = new HTTPBuilder()
  .withOpenTelemetry({
    serviceName: 'checkout-service',
    baggage: { 'user.tier': 'premium' },
  })
  .build()
 
// Continuing a distributed trace from an incoming request
import { parseTraceparent } from 'reixo'
 
const parentCtx = parseTraceparent(req.headers['traceparent'])
const tracedClient = new HTTPBuilder()
  .withOpenTelemetry({ parentContext: parentCtx ?? undefined })
  .build()

Migrating from axios

The reixo API is intentionally similar to axios for easy migration:

// Before — axios
import axios from 'axios'
const api = axios.create({ baseURL: 'https://api.example.com' })
const res = await api.get('/users')
res.data  // User[]
 
// After — reixo (same shape, Result wrapping optional)
import { HTTPBuilder } from 'reixo'
const api = new HTTPBuilder().withBaseURL('https://api.example.com').build()
const res = await api.get<User[]>('/users')
res.data  // User[] — identical structure

Runtime Support

EnvironmentSupport
Node.js 18+yes
Bun 1.0+yes
Deno 1.28+yes
Cloudflare Workersyes
Vercel Edgeyes
Modern Browsersyes

Zero dependencies — uses native fetch and AbortController only.

Common Mistakes

Using the throwing API and forgetting try/catch. Either use the try* methods for Result-based handling, or wrap every client.get() call in try/catch. Mixing both styles inconsistently creates coverage gaps.

Not configuring a timeout. Without .withTimeout(), long-running requests block indefinitely. Always set a reasonable timeout (5–30 seconds depending on the operation).

Opening too many circuit breakers per request. Create one shared client per downstream service — each client has its own breaker state.

Best Practices

  • Create one HTTPBuilder client instance per downstream service and reuse it across the app — this ensures the circuit breaker and deduplication state are shared.
  • Use the tryGet / tryPost family of methods for all production code — the Result pattern eliminates uncaught promise rejections.
  • Set withRetry for idempotent endpoints and withCircuitBreaker for any service that might go down under load.
  • Combine withDeduplication and withCache for public read endpoints that many components query on mount.
  • Use withOpenTelemetry with a parentContext parsed from the incoming request headers to propagate distributed traces through your service mesh.

Key Takeaways

  • reixo is a zero-dependency TypeScript HTTP client that runs on Node.js, Bun, Deno, Cloudflare Workers, and browsers.
  • The tryGet / tryPost methods return Result<T, E> — a discriminated union that forces error handling at the type level without try/catch.
  • Built-in retry with exponential backoff and jitter prevents thundering herd problems when recovering from server-side failures.
  • The circuit breaker opens after a configurable failure threshold and stops requests immediately, preventing cascading failures in distributed systems.
  • Request deduplication collapses simultaneous identical GET requests into a single network call — useful on page load when multiple components fetch the same resource.
  • LRU caching supports stale-while-revalidate, cache-first, and network-first strategies configurable per client.
  • OpenTelemetry tracing injects W3C traceparent headers with no @opentelemetry/* peer dependencies required.
  • Migrating from axios is straightforward — the response shape (res.data) is identical, making it a drop-in replacement.

Advertisement

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro

Related reading