Skip to main content

Wretch vs ky vs ofetch: Modern HTTP Client Libraries in 2026

·PkgPulse Team

TL;DR

ky is Sindre Sorhus's modern fetch wrapper — tiny, tree-shakable, and works everywhere (browser, Node.js 18+, Deno, Bun). It adds retry, timeout, and JSON helpers on top of the native Fetch API. Wretch takes a builder pattern approach — chainable middleware, plugins, and the cleanest API for configuring base URLs and headers. ofetch is from the Nuxt/Unjs team — smart JSON auto-detection, built-in retry, and works in all runtimes including edge workers. All three are modern Fetch API wrappers designed to replace Axios in 2026 without the 40KB overhead.

Key Takeaways

  • ky: ~5M weekly downloads — tiny, typed, retry/timeout built-in, Sindre Sorhus quality
  • wretch: ~2M weekly downloads — builder/middleware pattern, plugin system, best DX
  • ofetch: ~8M weekly downloads — Nuxt ecosystem, auto JSON, edge-native
  • All three are lighter than Axios (~40KB) — ky and ofetch are ~5KB
  • Retry with exponential backoff is built into ky and ofetch (manual in Axios)
  • If you're already using Axios, it's fine — but for new projects, these are better

PackageWeekly DownloadsBundle SizeEdge RuntimeRetryMiddleware
ky~5M~5KB✅ Built-in✅ Hooks
wretch~2M~3KB✅ Plugin✅ Excellent
ofetch~8M~5KB✅ Built-in✅ Interceptors

ky

ky — Sindre Sorhus's Fetch API upgrade:

Basic usage

import ky from "ky"

// GET with automatic JSON parsing:
const pkg = await ky.get("https://api.pkgpulse.com/packages/react").json<Package>()
// .json() is type-safe — returns Package, not any

// POST with JSON body:
const alert = await ky
  .post("https://api.pkgpulse.com/alerts", {
    json: { packageName: "react", threshold: 20, email: "user@example.com" },
  })
  .json<Alert>()

// Throws on 4xx/5xx — no need to check response.ok manually

Configuration

import ky from "ky"

// Create configured instance:
const api = ky.create({
  prefixUrl: "https://api.pkgpulse.com",
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
    "Content-Type": "application/json",
  },
  timeout: 10000,          // 10 second timeout
  retry: {
    limit: 3,              // Retry failed requests up to 3 times
    delay: (attemptCount) => 1000 * Math.pow(2, attemptCount),  // Exponential backoff
    methods: ["get", "put", "head", "delete", "options", "trace"],
    statusCodes: [408, 413, 429, 500, 502, 503, 504],
  },
})

// Use the instance:
const packages = await api.get("packages").json<Package[]>()
const stats = await api.get("packages/react/stats").json<Stats>()

Hooks (middleware)

import ky from "ky"

const api = ky.create({
  prefixUrl: "https://api.pkgpulse.com",
  hooks: {
    // Before request:
    beforeRequest: [
      (request) => {
        // Add auth token from storage:
        const token = localStorage.getItem("access_token")
        if (token) request.headers.set("Authorization", `Bearer ${token}`)
      },
    ],

    // Before retry:
    beforeRetry: [
      async ({ request, options, error, retryCount }) => {
        console.log(`Retry attempt ${retryCount}...`)
        // If 401, refresh token before retry:
        if ((error as Response)?.status === 401) {
          const newToken = await refreshAccessToken()
          request.headers.set("Authorization", `Bearer ${newToken}`)
        }
      },
    ],

    // After response:
    afterResponse: [
      (request, options, response) => {
        // Track API usage:
        analytics.track("api_call", {
          url: request.url,
          status: response.status,
        })
        return response
      },
    ],

    // On error (4xx/5xx):
    beforeError: [
      async (error) => {
        const { response } = error
        if (response?.status === 429) {
          const retryAfter = parseInt(response.headers.get("Retry-After") || "60")
          await new Promise((r) => setTimeout(r, retryAfter * 1000))
        }
        return error
      },
    ],
  },
})

Error handling

import ky, { HTTPError } from "ky"

try {
  const data = await ky.get("https://api.pkgpulse.com/packages/nonexistent").json()
} catch (err) {
  if (err instanceof HTTPError) {
    // HTTP error (4xx/5xx):
    const status = err.response.status
    const body = await err.response.json()
    console.error(`HTTP ${status}:`, body)
  } else if (err instanceof TimeoutError) {
    console.error("Request timed out")
  } else {
    console.error("Network error:", err)
  }
}

Wretch

Wretch — the builder pattern for fetch:

Builder API

import wretch from "wretch"

// Build an API client with method chaining:
const api = wretch("https://api.pkgpulse.com")
  .auth(`Bearer ${process.env.API_KEY}`)     // Authorization header
  .accept("application/json")                // Accept header
  .content("application/json")              // Content-Type header
  .timeout(10000)                           // 10 second timeout

// GET:
const pkg = await api
  .url("/packages/react")
  .get()
  .json<Package>()

// POST:
const alert = await api
  .url("/alerts")
  .post({ packageName: "react", threshold: 20, email: "user@example.com" })
  .json<Alert>()

// Query params:
const results = await api
  .url("/search")
  .query({ q: "react", category: "ui", limit: 10 })
  .get()
  .json<SearchResults>()

Middleware (addon) system

import wretch from "wretch"
import { retry } from "wretch/addons"

const api = wretch("https://api.pkgpulse.com")
  .addon(retry({
    delayTimer: 500,
    delayRamp: (delay, nbOfAttempts) => delay * nbOfAttempts,  // Exponential backoff
    maxAttempts: 3,
    until: (response) => response && response.ok,
    onRetry: ({ url, options, error, response }) => {
      console.log(`Retrying ${url}...`)
    },
    retryOnNetworkError: true,
  }))
  .auth(`Bearer ${process.env.API_KEY}`)

Error catcher chains

const data = await api
  .url("/packages/react")
  .get()
  .unauthorized((error) => {
    // Handles 401:
    redirectToLogin()
    return null
  })
  .notFound(() => {
    // Handles 404:
    return null
  })
  .fetchError((error) => {
    // Network errors:
    console.error("Network error:", error)
    return null
  })
  .json<Package | null>()

// Clean error handling without try/catch boilerplate

ofetch

ofetch — from the Unjs/Nuxt ecosystem:

Basic usage

import { ofetch } from "ofetch"

// GET — auto-detects JSON:
const pkg = await ofetch<Package>("https://api.pkgpulse.com/packages/react")
// Automatically parses JSON response — no .json() call needed

// POST with body:
const alert = await ofetch<Alert>("/api/alerts", {
  method: "POST",
  body: { packageName: "react", threshold: 20 },  // Auto-serializes to JSON
})

// With query params:
const results = await ofetch<SearchResults>("/api/search", {
  query: { q: "react", limit: 10 },
})

Create instance

import { $fetch } from "ofetch"

// Global $fetch (Nuxt-style) or create instance:
const api = $fetch.create({
  baseURL: "https://api.pkgpulse.com",
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
  },
  retry: 3,           // Auto-retry on failure
  retryDelay: 500,    // 500ms between retries
  timeout: 10000,

  // Request interceptor:
  async onRequest({ request, options }) {
    options.headers = {
      ...options.headers,
      "X-Request-ID": crypto.randomUUID(),
    }
  },

  // Response interceptor:
  async onResponse({ request, response, options }) {
    if (response.status === 200) {
      cache.set(request.toString(), response._data)
    }
  },

  // Error handler:
  async onResponseError({ request, response, options }) {
    if (response.status === 401) {
      await refreshTokens()
    }
  },
})

const pkg = await api<Package>("/packages/react")

Feature Comparison

Featurekywretchofetch
Bundle size~5KB~3KB~5KB
TypeScript
Auto JSON.json().json()✅ Auto-detect
Built-in retry✅ Addon
Timeout
Hooks/Interceptors✅ Addon
Builder API
Error catcher chains
Query params✅ searchParams✅ query✅ query
Edge runtime
Nuxt integration✅ Built-in

When to Use Each

Choose ky if:

  • You want a minimal Axios replacement with TypeScript and retry
  • You're in the browser or Node.js 18+ without a framework
  • Sindre Sorhus quality and ESM-first design matters to you
  • Simple, hook-based middleware is enough

Choose Wretch if:

  • You love the builder/fluent chain API
  • You want named error catchers (.unauthorized, .notFound) vs try/catch
  • You need a modular addon system for retry, abort, etc.
  • API client configuration is complex and you want it composable

Choose ofetch if:

  • You're building with Nuxt.js or the Unjs ecosystem
  • Auto-detect JSON (no .json() needed) is preferred
  • Edge runtimes (Cloudflare Workers) are primary targets
  • You want the $fetch global pattern familiar from Nuxt

Methodology

Download data from npm registry (weekly average, February 2026). Bundle sizes from bundlephobia. Feature comparison based on ky v1.x, wretch v2.x, and ofetch v1.x.

Compare HTTP client and networking packages on PkgPulse →

Comments

Stay Updated

Get the latest package insights, npm trends, and tooling tips delivered to your inbox.