Wretch vs ky vs ofetch: Modern HTTP Client Libraries in 2026
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 — 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
| 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
$fetchglobal 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.