<!-- PkgPulse AI-readable guide source -->
<!-- Canonical: https://www.pkgpulse.com/guides/wretch-vs-ky-vs-ofetch-modern-http-client-libraries-2026 -->
<!-- Raw Markdown: https://www.pkgpulse.com/guides/wretch-vs-ky-vs-ofetch-modern-http-client-libraries-2026/raw.md -->
<!-- Source path: content/guides/wretch-vs-ky-vs-ofetch-modern-http-client-libraries-2026.mdx -->

---
og_image: "/images/guides/wretch-vs-ky-vs-ofetch-modern-http-client-libraries-2026.webp"
title: "Wretch vs ky vs ofetch: Modern HTTP Client 2026"
description: "Compare Wretch, ky, and ofetch for modern HTTP requests in JavaScript. Middleware chains, retry logic, TypeScript, edge runtime support, and which HTTP."
date: "2026-03-09"
author: "PkgPulse Team"
tags: ["javascript", "typescript", "nodejs", "api"]
---

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

---

## Download Trends

| Package | Weekly Downloads | Bundle Size | Edge Runtime | Retry | Middleware |
|---------|-----------------|-------------|-------------|-------|-----------|
| `ky` | ~5M | ~5KB | ✅ | ✅ Built-in | ✅ Hooks |
| `wretch` | ~2M | ~3KB | ✅ | ✅ Plugin | ✅ Excellent |
| `ofetch` | ~8M | ~5KB | ✅ | ✅ Built-in | ✅ Interceptors |

---

## ky

[ky](https://github.com/sindresorhus/ky) — Sindre Sorhus's Fetch API upgrade:

### Basic usage

```typescript
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

```typescript
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)

```typescript
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

```typescript
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](https://elbywan.github.io/wretch) — the builder pattern for fetch:

### Builder API

```typescript
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

```typescript
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

```typescript
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](https://github.com/unjs/ofetch) — from the Unjs/Nuxt ecosystem:

### Basic usage

```typescript
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

```typescript
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

| Feature | ky | wretch | ofetch |
|---------|-----|--------|--------|
| 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:

```typescript
// 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 →](https://www.pkgpulse.com)*

*See also: [Axios vs Ky](/compare/axios-vs-ky) and [AVA vs Jest](/compare/ava-vs-jest), [sanitize-html vs DOMPurify vs xss](/guides/sanitize-html-vs-dompurify-vs-xss-xss-prevention-2026).*
