A small brass key on a desk beside neatly trimmed cable, with status-code paper tags on a wire and a gray-blue cat watching from a shelf.

Ky: Fetch With the Sharp Edges Sanded Off

The Gray Cat
The Gray Cat
0 views

You have written this code a hundred times. You call fetch, then check response.ok, then throw your own error if it is not, then call response.json(), then maybe wrap the whole thing in a retry loop because the API flakes out under load, then wire up an AbortController because fetch has no timeout. By the third endpoint you have copied the same fifteen lines into a helper, and that helper slowly grows into a half-baked HTTP library of your own.

ky is that library, except finished, tested, and shipped by someone else. It is a tiny HTTP client built directly on top of the Fetch API by Sindre Sorhus, the maintainer behind got, chalk, and execa. The key idea is that Ky is a thin wrapper, not a reimplementation. It keeps the standards-based fetch model intact, so you still deal with real Request and Response objects and standard AbortSignal cancellation. It just removes the boilerplate that makes raw fetch tedious in a real application. The result is roughly "axios ergonomics on a fetch foundation," in about 4 KB minified and gzipped, with zero runtime dependencies.

Why Not Just Use fetch?

Native fetch is genuinely good, and it is now built into every browser, plus Node, Bun, and Deno. But it was designed to be low-level, and that shows up as a series of small frustrations the moment you build something serious.

The biggest one is error handling. A fetch call only rejects on a network failure. A 404 or a 500 resolves perfectly happily, so you have to manually check response.ok on every single call or risk treating an error page as valid data. Ky flips this to the behavior most people actually expect: any non-2xx status throws an HTTPError.

Sending and receiving JSON is two awkward steps each in plain fetch. You stringify the body and set the Content-Type header yourself going out, and you await response.json() coming back. Ky gives you a json option to send and a chained .json() to read. On top of that, raw fetch has no retries, no timeout, no concept of a base URL or a preconfigured instance, and no clean interception point for things like injecting an auth token. Ky packages all of that in, and because it leans on the platform fetch underneath, it carries none of its own transport code.

Getting It Installed

Ky ships as ESM-only with TypeScript types built in. Install it like anything else:

npm install ky

Or with yarn:

yarn add ky

It runs natively in modern browsers, Node.js 22 and up, Bun, and Deno. One historical note worth knowing: years ago, when Node had no global fetch, you needed a companion package called ky-universal to polyfill it. That package is now deprecated, and its own docs simply tell you to use Ky directly. If you are on an older Node that predates global fetch, pin to Ky v1, which has a lower runtime floor.

Making Requests That Read Like Sentences

The first thing you will notice is how compact a request becomes. A GET that parses JSON is a single expression:

import ky from 'ky';

const users = await ky.get('https://example.com/api/users').json();

Posting JSON is just as tidy. You pass the json option and Ky stringifies the body and sets the Content-Type header for you:

const created = await ky
  .post('https://example.com/api/users', { json: { name: 'Ada' } })
  .json();

Ky gives you the usual method shortcuts, ky.get(), ky.post(), ky.put(), ky.patch(), ky.head(), and ky.delete(), and you can also call ky(input, options) directly. The clever bit is the return value. Instead of a plain Promise<Response>, Ky returns a response promise augmented with body-parsing shortcuts. That is what lets you chain .json() straight onto the call without an intermediate await. The same trick works for .text(), .blob(), .arrayBuffer(), .formData(), and .bytes().

Because Ky is built on fetch, TypeScript fits naturally. You tell .json() what shape you expect and it types the result:

type User = { id: number; name: string };

const user = await ky('/api/users/1').json<User>();

In v2 you can go a step further and hand .json() a Standard Schema validator from a library like Zod, Valibot, or ArkType. Ky runs the response through it and throws if the data does not match, so you get runtime safety, not just compile-time hopes:

import { z } from 'zod';

const schema = z.object({ id: z.number(), name: z.string() });
const validated = await ky('/api/user').json(schema);

Retries and Timeouts That Already Work

This is where Ky starts saving you from code you would otherwise write badly. Out of the box, Ky retries failed requests on transient status codes with exponential backoff. The defaults are sensible: it retries up to twice, only on idempotent methods like GET and DELETE, only on codes that suggest a temporary problem (408, 413, 429, 500, 502, 503, 504), and it backs off exponentially between attempts. It also honors a Retry-After header automatically for 429 and 503 responses, so a well-behaved API can tell Ky exactly when to come back.

You can tune any of this. Pass a number for a quick override of the attempt count, or a full object for fine control:

await ky('https://example.com', {
  retry: {
    limit: 5,
    statusCodes: [401, 429, 503],
    delay: attemptCount => attemptCount * 1000,
  },
});

// Or just bump the attempt count
await ky('https://example.com', { retry: 3 });

Timeouts are equally painless. Where raw fetch makes you wire up an AbortController by hand, Ky has a timeout option that defaults to 10 seconds per attempt; set it to false to disable it. In v2 there is also a totalTimeout that caps the entire budget across all retries, so a flapping endpoint cannot keep you waiting indefinitely. Standard cancellation still works exactly as you would expect, because Ky passes your signal straight through to fetch:

const controller = new AbortController();
const promise = ky('https://example.com', { signal: controller.signal });
controller.abort();

Building Preconfigured Clients

Repeating the same host, headers, and options on every call is the kind of duplication that ages a codebase badly. Ky solves it with instances. ky.create() builds a brand-new client with its own defaults, and ky.extend() layers more options on top of an existing one by deep-merging them. Deep-merging means header objects combine and hook arrays concatenate rather than clobbering each other, which makes it easy to build a base client and then specialize it:

const api = ky.create({
  baseUrl: 'https://api.example.com/v1/',
  headers: { 'x-app': 'web' },
});

const authedApi = api.extend({
  headers: { authorization: `Bearer ${getToken()}` },
});

A note on naming if you are reading older tutorials: v1 had a single prefixUrl option, while v2 splits this into a prefix string that is prepended to the input and a baseUrl that resolves using standard URL rules. Most blog content online still shows the v1 form, so check which version a snippet targets before copying it.

Hooks: The Interception Layer

If create and extend handle configuration, hooks handle behavior. They are Ky's answer to axios interceptors, and because each hook is an array of functions, instances can compose them cleanly. The main ones are beforeRequest to mutate the outgoing request, beforeRetry to run logic before each retry, afterResponse to inspect or replace the response, and beforeError to enrich an error before it is thrown. There is also an init hook in v2 for synchronously adjusting options before the request is even constructed.

The classic use case is authentication. You inject a token on the way out, and you can refresh it on a 401 by triggering a retry from afterResponse:

const api = ky.create({
  baseUrl: 'https://api.example.com/',
  hooks: {
    beforeRequest: [
      request => {
        request.headers.set('Authorization', `Bearer ${getToken()}`);
      },
    ],
    beforeError: [
      error => {
        const { response } = error;
        if (response) error.message = `Request failed: ${response.status}`;
        return error;
      },
    ],
  },
});

One subtlety worth flagging: in v2 the hook signatures were unified so each hook receives a single state object rather than positional arguments. The older v1 style passed (request, options, response) separately. If a snippet destructures { request, options, response }, it is v2; if it lists them as bare parameters, it is v1.

There is also a small escape hatch named ky.stop. Return it from a beforeRetry hook and Ky stops retrying silently without throwing, which is handy when you decide mid-flight that further attempts are pointless.

Handling Errors Like You Mean It

Because Ky throws on non-2xx responses, your error handling becomes a clean try/catch with real typed errors. The main one is HTTPError, which carries the response, request, and options. In v2 the response body is read for you and stashed on error.data, capped at 10 MiB, which sidesteps the classic "body already used" trap you hit when you try to read a response twice:

import ky, { HTTPError } from 'ky';

try {
  await ky.get('https://example.com/missing').json();
} catch (error) {
  if (error instanceof HTTPError) {
    const status = error.response.status; // 404
    // v2: const body = error.data;
  }
}

Alongside HTTPError you get TimeoutError when a request blows its timeout budget and, in v2, a NetworkError for transport-level failures with the original cause attached. V2 also adds type guards like isHTTPError(), isTimeoutError(), and isKyError() for when an instanceof check is awkward across module boundaries.

Where Ky Fits

It helps to be honest about the landscape. The closest comparison is native fetch itself: if you are barely doing any HTTP and do not mind the response.ok dance, plain fetch is fine and free. Reach for Ky the moment you find yourself copying that boilerplate around. Against axios, the trade is size and standards versus ecosystem and legacy reach. Axios is around 13 to 15 KB, brings its own transport, and shines when you need XHR-specific features or very old browser support; Ky is a quarter of the size, standards-based, and universal across browser and server. Among the other fetch wrappers, ofetch is the natural pick inside the Nuxt and UnJS world, and wretch appeals if you prefer a fluent chaining style, while redaxios is an axios-compatible shim for code already written against that API. And Ky's older sibling got is the heavyweight choice, but it is Node-only.

In a React app, Ky most often shows up as the fetcher inside a TanStack Query or SWR query function. Those libraries handle caching and state; Ky handles the actual request, the retries, and the auth headers, and the two compose without friction.

The honest catches are short. Ky is ESM-only, so there is no CommonJS require. The v2 line needs Node 22, so older runtimes should pin v1. And because it is a thin layer over fetch, it cannot do anything fetch itself cannot, such as reliable upload progress in every environment without HTTP/2. None of that is likely to matter for a modern frontend or full-stack project. For everyone else who has been quietly maintaining a homegrown fetch helper, Ky is the small, sharp, zero-dependency upgrade that lets you finally delete it.