Skip to main content

Guide

Wretch vs ky vs ofetch: Modern HTTP Client 2026

Compare Wretch, ky, and ofetch for modern HTTP requests in JavaScript. Middleware chains, retry logic, TypeScript, edge runtime support, and which HTTP.

·PkgPulse Team·
0

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

Migration Guide

From Axios to ky

Ky is the most direct Axios replacement — both have an instance-based API with interceptors:

// Axios (old)
import axios from "axios"
const api = axios.create({ baseURL: "https://api.pkgpulse.com", timeout: 10000 })
api.interceptors.request.use((config) => {
  config.headers.Authorization = `Bearer ${getToken()}`
  return config
})
const { data } = await api.get<Package>("/packages/react")

// ky (new)
import ky from "ky"
const api = ky.create({
  prefixUrl: "https://api.pkgpulse.com",
  timeout: 10000,
  hooks: {
    beforeRequest: [(request) => {
      request.headers.set("Authorization", `Bearer ${getToken()}`)
    }],
  },
})
const pkg = await api.get("packages/react").json<Package>()

Key difference: ky uses prefixUrl (not baseURL), paths must not start with /, and the response is read with .json() typed method instead of destructuring data.

Community Adoption in 2026

ky reaches approximately 3 million weekly downloads, driven by sindresorhus's ecosystem of ESM-first packages and strong TypeScript support. It is the de facto choice for browser-only or browser-primary applications that need a lightweight fetch wrapper. Its lack of Node.js-specific features (streams, agent configuration) is a feature, not a bug, for browser-focused codebases.

ofetch reaches approximately 5 million weekly downloads, boosted enormously by Nuxt's ecosystem. Every Nuxt 3+ application uses ofetch through $fetch, making it one of the most widely deployed HTTP libraries even though many developers interact with it through the Nuxt abstraction rather than directly. Its auto-detection of JSON response bodies (no .json() call needed) and consistent behavior across Node.js, browser, and edge runtimes make it the smoothest option for universal (SSR + CSR) applications.

wretch reaches approximately 800,000 weekly downloads, serving a niche of developers who prefer the fluent builder API style. Its named error catchers (.unauthorized(), .notFound()) replace generic try/catch blocks with domain-specific handlers, reducing boilerplate in API client code. Teams with complex authentication refresh logic particularly benefit from wretch's middleware addon system.

Error Handling Patterns and Retry Strategy

The way each library handles HTTP errors and retry behavior is a key differentiator for production applications where network reliability matters.

wretch's error handling uses a middleware chain model: .catcher(code, handler) registers handlers for specific HTTP status codes, and .fetchError(handler) handles network-level failures (no response). The .resolve(resolver) method transforms the final response. This produces a declarative error-handling specification that reads like a contract: "on 401, refresh token and retry; on 403, redirect to permissions page; on 5xx, show error toast." The middleware chain runs in registration order, making the error flow predictable and auditable.

ky's retry behavior is built-in with sensible defaults: retry GET requests up to 2 times on 408, 413, 429, 500, 502, 503, and 504 status codes, with exponential backoff. The retry option accepts an integer (number of retries) or a detailed config object specifying which methods and status codes to retry, maximum delay, and backoff coefficient. ky automatically respects Retry-After headers from rate-limiting responses. This built-in retry capability removes a common source of manual implementation errors — teams often implement retry logic incorrectly (retrying non-idempotent POST requests, not respecting Retry-After, not implementing exponential backoff).

ofetch's error handling throws FetchError objects that include both the error and the parsed response body. This is particularly useful for API clients where the error response body contains useful information: error.data gives you the JSON body of the error response without a separate .json() call. ofetch also propagates response body parsing automatically on errors, which removes a common async error handling pitfall where developers catch the error but forget to await error.response.json() to read the error detail.

For production API clients, the recommended pattern is to wrap your HTTP client in an application-level service class that handles authentication (token injection, refresh), error normalization (converting library-specific errors to your domain error types), and request logging. This abstraction makes it easier to swap HTTP client libraries later and centralizes cross-cutting concerns. All three libraries support this pattern well through their middleware/hooks systems.

TypeScript Integration and Request/Response Typing

All three libraries provide generic type parameters for response data, but the ergonomics differ in ways that affect day-to-day productivity. ky's .json<Package>() typed method is clean but requires a separate call to deserialize the response — you cannot get the typed result directly from the initial request call. ofetch's approach inlines the type at the call site: ofetch<Package>('/api/packages/react') returns Promise<Package> directly because ofetch auto-parses JSON by default. This makes ofetch's API the most concise for the common case of typed JSON responses. wretch's .json<Package>() terminal method works similarly to ky. For request body typing, all three accept object or unknown for the body, with no compile-time enforcement that your request body matches a specific interface — that type safety must be added manually or through a schema library. A common pattern is to create a typed wrapper around your HTTP client that accepts a specific input type and returns a specific output type, hiding the underlying library's generics behind your application's domain types.

Edge Runtime Compatibility and Bundle Considerations

Running HTTP client code in edge runtimes like Cloudflare Workers or Vercel Edge Functions imposes constraints that distinguish these libraries from Axios. All three — ky, wretch, and ofetch — are built on the native Fetch API and have no Node.js-specific dependencies, which means they run without modification in edge environments where Node.js built-ins like http, https, and stream are unavailable. This is their primary advantage over Axios in 2026: edge runtimes are a real deployment target, and teams that standardize on a fetch-based client can use the same HTTP abstraction in serverless functions, edge workers, and client-side code without maintaining separate implementations. The bundle size difference also matters in edge contexts where cold start time correlates with bundle size — ky and ofetch at approximately 5KB each contribute negligibly to cold start latency compared to Axios's 40KB.

All three libraries serialize request bodies as JSON when passed an object to body or json options, setting Content-Type: application/json automatically. For multipart form data (file uploads), pass a FormData object directly — all three libraries pass it through to the underlying fetch call without additional serialization. For URL-encoded form data (application/x-www-form-urlencoded), use URLSearchParams as the body. These are standard fetch behaviors that all three libraries inherit from the underlying Fetch API rather than implementing independently.

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 →

See also: Axios vs Ky and AVA vs Jest, sanitize-html vs DOMPurify vs xss.

The 2026 JavaScript Stack Cheatsheet

One PDF: the best package for every category (ORMs, bundlers, auth, testing, state management). Used by 500+ devs. Free, updated monthly.