ky
Ky is a dependency-free HTTP client built on the standard Fetch API for modern browsers, Node.js, Bun, and Deno. It keeps Request, Response, Headers, AbortSignal, and fetch options, then adds method shortcuts, automatic JSON bodies, non-2xx errors, retry and timeout policy, hooks, progress callbacks, configured instances, and typed or schema-validated JSON helpers. It is a convenience layer, not a separate transport stack.
Ky is the nicest upgrade from bare fetch when its Node 22 and modern-browser floor matches your project. Skip it for simple calls, CommonJS services, or large streaming uploads where its retry machinery is a liability.
Use it if
- You like fetch semantics but want retries, timeouts, hooks, JSON shortcuts, and non-2xx errors in one small client
- You need the same dependency-free request wrapper in modern browsers and Node 22 or newer
- You want TypeScript JSON results to default to unknown and optionally validate responses with a Standard Schema validator
- You need reusable client instances with base URL, headers, retry policy, instrumentation, or authentication hooks
- You only make a few straightforward requests: native fetch avoids another API layer and already exists in every runtime Ky 2 targets
- Your server runs Node 20 or older: the 2.0.2 package declares Node 22 as its minimum engine
- Your codebase is CommonJS-first: the published package is type module and exposes an ESM default export, so require-based projects need migration or a different client
- You upload large ReadableStream bodies with retries enabled: the README warns that Ky tees and buffers the entire stream for each attempt; disable retries for that request
- You expect fetch's error behavior or empty JSON tolerance: Ky throws HTTPError on non-2xx responses, and its json shortcut also throws for an empty body or status 204
Setup reality
`npm install ky` brings no dependencies or peer dependencies, and TypeScript declarations are included. The important gate is runtime: Ky 2.0.2 declares Node 22 or newer and is an ESM package. Modern browsers, Bun, and Deno are supported because they provide Fetch API primitives; older browsers and CommonJS-only Node applications are not the target. Relative URLs work in a browser, but server-side calls need an absolute destination or a suitable `baseUrl`. Include a trailing slash when a base URL contains a path, because normal URL resolution otherwise replaces the final path segment. Ky changes fetch defaults in ways that deserve an explicit review. It throws on non-2xx responses, retries selected methods and status codes with a default limit of two, and applies a per-attempt timeout; timeout failures are not retried unless `retryOnTimeout` is enabled. Add `totalTimeout` if retry delays must fit an overall deadline. Request hooks receive normalized Request objects and run at specific lifecycle points, so old hook signatures from Ky 0.x examples are wrong for version 2. JSON response generics only tell TypeScript what you expect; they do not validate the wire data unless you pass a Standard Schema validator. Browser CORS rules remain unchanged. For streaming uploads, retries clone the body with `tee()` and buffer it in memory, so set the retry limit to zero. Finally, an HTTPError holds the response and parsed data, while network and timeout failures use different error classes; handle them separately rather than assuming every exception has a status.
Patterns
Fetch typed JSONget-json
import ky from 'ky';
type User = {id: string; name: string};
const user = await ky.get('https://api.example.com/users/42').json<User>();The generic is a compile-time assertion, not runtime validation. Without it, json returns unknown.
POST a JSON bodypost-json
import ky from 'ky';
const created = await ky.post('https://api.example.com/users', {
json: {name: 'Ada'},
}).json<{id: string}>();The json option stringifies the value and sets the content type. The json shortcut throws when the response body is empty.
Create a configured API clientcreate-client
import ky from 'ky';
export const api = ky.create({
baseUrl: 'https://api.example.com/v1/',
timeout: 10_000,
totalTimeout: 30_000,
headers: {'X-Client': 'dashboard'},
});
const users = await api.get('users').json();Keep the trailing slash on a base URL with a path so URL resolution appends users instead of replacing v1.
Serialize query parameterssend-query-params
const results = await api.get('search', {
searchParams: {q: 'open source', page: 2, archived: false},
}).json();searchParams uses URLSearchParams semantics; undefined handling and array formats should be made explicit for APIs with custom conventions.
Attach authentication in a request hookattach-auth-header
const authedApi = api.extend({
hooks: {
beforeRequest: [({request}) => {
const token = getAccessToken();
if (token) request.headers.set('Authorization', `Bearer ${token}`);
}],
},
});Ky 2 hooks receive a state object. beforeRequest runs once before retry handling, so refresh-per-retry logic belongs in beforeRetry or afterResponse.
Bound retries with jitter and a total timeoutconfigure-retries
const data = await api.get('reports', {
retry: {
limit: 3,
statusCodes: [408, 429, 500, 502, 503, 504],
jitter: true,
retryOnTimeout: true,
},
timeout: 5_000,
totalTimeout: 20_000,
}).json();timeout is per attempt; totalTimeout covers retry delays and all attempts. Retrying non-idempotent work can duplicate side effects.
Upload a stream without retry bufferingdisable-stream-retries
await ky.post('https://api.example.com/import', {
body: readableStream,
retry: {limit: 0},
headers: {'content-type': 'application/octet-stream'},
});The README warns that enabled retries tee and buffer the whole ReadableStream in memory.
Read a structured HTTP errorhandle-http-error
import ky, {isHTTPError} from 'ky';
try {
await api.get('users/missing').json();
} catch (error) {
if (isHTTPError(error)) {
console.error(error.response.status, error.data);
} else {
throw error;
}
}NetworkError and TimeoutError do not have an HTTP response; use the provided type guards rather than reading status unconditionally.
Inspect non-2xx responses without throwingallow-non-2xx
const response = await ky.get('https://api.example.com/health', {
throwHttpErrors: false,
});
if (!response.ok) console.error(response.status);This restores fetch-like status handling for HTTP responses; network and timeout errors can still throw.
Cancel with AbortControllercancel-request
const controller = new AbortController();
const request = api.get('slow-report', {signal: controller.signal}).json();
controller.abort();
await request;The promise rejects after abort. Catch the error when cancellation is an expected user action.
Validate JSON with a Standard Schemavalidate-response
import {z} from 'zod';
const userSchema = z.object({id: z.string(), name: z.string()});
const user = await api.get('users/42').json(userSchema);A schema mismatch throws SchemaValidationError. Use a validator version that implements Standard Schema, such as the version range named in Ky's README.
Track download progresstrack-download
const file = await ky.get('https://cdn.example.com/archive.zip', {
onDownloadProgress: (progress, chunk) => {
console.log(progress.percent, chunk.byteLength);
},
}).arrayBuffer();Progress handling reads the body through Ky. The total can be unavailable when the server does not send a usable content length.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| axios | npm | You need older Node or browser coverage, CommonJS compatibility, or axios's established interceptor ecosystem |
| got | npm | You are Node-only and want deeper transport controls, streams, pagination, hooks, and retry policy |
| ofetch | npm | You want a compact universal fetch wrapper with strong Nuxt and server-runtime integration |