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