Featured image of post ky: Stop Writing Fetch Boilerplate

ky: Stop Writing Fetch Boilerplate

ky is a tiny fetch-based HTTP client β€” 4KB gzip, zero dependencies. Built-in retry with exponential backoff, timeout, hooks (like interceptors), JSON shorthand. 3x smaller than axios, half the code of raw fetch.

Every project using fetch ends up with the same boilerplate: if (!response.ok) throw new Error(...). Add retry and you’re writing a loop. Add timeout and you’re pulling out AbortController. ky wraps all of that. 4KB, zero dependencies.

What’s Annoying About fetch

The Fetch API has a few friction points that come up in every project:

1. HTTP errors don’t throw automatically

1
2
3
4
5
const response = await fetch('/api/users/1');
if (!response.ok) {  // 404 and 500 don't throw β€” you have to check manually
  throw new Error(`HTTP ${response.status}`);
}
const user = await response.json();  // second await required

2. Posting JSON requires boilerplate

1
2
3
4
5
const response = await fetch('/api/users', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },  // must set manually
  body: JSON.stringify({ name: 'Alice' }),           // must serialize manually
});

3. No built-in retry or timeout

Neither exists. Want retry? Write a loop. Want timeout? Wire up AbortController. Every project copy-pastes the same logic.

ky solves all of this while keeping the familiar fetch feel.

Installation

1
npm install ky

Supports Node.js 22+, Bun, Deno, and modern browsers.

Basic Usage Comparison

1
2
3
4
5
6
7
8
9
import ky from 'ky';

// GET + automatic JSON parsing (used to require two awaits)
const users = await ky.get('/api/users').json();

// POST JSON (no manual Content-Type or JSON.stringify)
const newUser = await ky.post('/api/users', {
  json: { name: 'Alice', role: 'admin' }
}).json();

The raw fetch equivalent is six to seven extra lines β€” repeated in every file.

Built-in Retry

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// Retry up to 3 times with exponential backoff
const data = await ky.get('/api/data', {
  retry: 3
}).json();

// Fine-grained control
const data = await ky.get('/api/data', {
  retry: {
    limit: 5,
    statusCodes: [408, 429, 500, 502, 503, 504],  // which status codes trigger retry
    backoffLimit: 3000,   // cap wait at 3 seconds
    jitter: true,         // add randomness to prevent thundering herd
  }
}).json();

On 429 Too Many Requests, ky automatically reads the Retry-After header to determine how long to wait.

Built-in Timeout

Default is 10 seconds. Throws TimeoutError when exceeded.

1
2
3
4
5
6
7
8
const data = await ky.get('/api/slow', {
  timeout: 5000   // 5 seconds
}).json();

// Disable timeout
const data = await ky.get('/api/stream', {
  timeout: false
}).json();

Hooks: Intercept Requests and Responses

ky’s hooks are the equivalent of axios interceptors. Four lifecycle points are available:

beforeRequest: Add auth headers

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
const api = ky.create({
  baseUrl: 'https://api.example.com/v1',
  hooks: {
    beforeRequest: [
      (request) => {
        const token = localStorage.getItem('token');
        if (token) {
          request.headers.set('Authorization', `Bearer ${token}`);
        }
      }
    ]
  }
});

Runs before every request. The token is read fresh from storage each time β€” never stale.

afterResponse: Silent token refresh on 401

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
const api = ky.create({
  hooks: {
    afterResponse: [
      async (request, options, response) => {
        if (response.status === 401) {
          const { accessToken } = await ky.post('/auth/refresh', {
            json: { refreshToken: localStorage.getItem('refreshToken') }
          }).json();

          localStorage.setItem('token', accessToken);
          request.headers.set('Authorization', `Bearer ${accessToken}`);

          // Retry the original request with the new token
          return ky(request);
        }
        return response;
      }
    ]
  }
});

beforeError: Attach server error message to the Error object

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
const api = ky.create({
  hooks: {
    beforeError: [
      async (error) => {
        if (error instanceof HTTPError) {
          const body = await error.response.clone().json().catch(() => ({}));
          error.message = body.message ?? error.message;
          error.data = body;  // attach the response body to the error
        }
        return error;
      }
    ]
  }
});

After this hook, catching the error gives you the server’s message directly β€” no need to call await error.response.json() in every catch block.

beforeRetry: Log retries

1
2
3
4
5
6
7
hooks: {
  beforeRetry: [
    ({ request, error, retryCount }) => {
      console.warn(`Retry #${retryCount}: ${request.url} β€” ${error.message}`);
    }
  ]
}

Shared Instance

Use ky.create() to build a configured instance and share it across the project:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// lib/api.ts
import ky, { HTTPError } from 'ky';

export const api = ky.create({
  baseUrl: 'https://api.example.com/v1',
  timeout: 15_000,
  retry: {
    limit: 2,
    statusCodes: [500, 502, 503, 504]
  },
  hooks: {
    beforeRequest: [
      (request) => {
        const token = localStorage.getItem('authToken');
        if (token) request.headers.set('Authorization', `Bearer ${token}`);
      }
    ],
    beforeError: [
      async (error) => {
        if (error instanceof HTTPError) {
          const body = await error.response.clone().json().catch(() => ({}));
          error.message = body.message ?? `HTTP ${error.response.status}`;
          error.data = body;
        }
        return error;
      }
    ]
  }
});

// Usage
const users = await api.get('users').json();
const user  = await api.get('users/1').json();
await api.post('posts', { json: { title: 'Hello' } });

extend: Inherit and add to an existing instance

1
2
3
4
// Inherit everything from api, add admin header
const adminApi = api.extend({
  headers: { 'X-Admin-Key': 'secret' }
});

Hooks are merged, not replaced β€” the beforeRequest from api still runs.

searchParams

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// Object (undefined is omitted, null is kept)
const results = await ky.get('/api/search', {
  searchParams: {
    query: 'typescript',
    page: 1,
    limit: 20,
    draft: undefined   // omitted β€” won't appear in URL
  }
}).json();
// β†’ GET /api/search?query=typescript&page=1&limit=20

TypeScript Types

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
interface User {
  id: number;
  name: string;
  email: string;
}

// Generic parameter gives you a typed result
const user = await api.get<User>('users/1').json();
// user is typed as User

// Combine with [Zod](/en/p/zod-typescript-validation/) for runtime validation
import { z } from 'zod';
const UserSchema = z.object({
  id: z.number(),
  name: z.string(),
  email: z.string().email()
});

const user = await api.get('users/1').json(UserSchema);
// Throws SchemaValidationError if response doesn't match the schema

Error Handling

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
import { HTTPError, TimeoutError } from 'ky';

try {
  const data = await api.get('users/999').json();
} catch (error) {
  if (error instanceof HTTPError) {
    console.log(error.response.status);  // 404, 500, etc.
    console.log(error.data);             // parsed server error body (if beforeError hook is set)
  } else if (error instanceof TimeoutError) {
    console.log('Request timed out');
  }
}

ky vs axios

Featurekyaxios
Bundle size~4KB gzip~14KB gzip
DependenciesZeroSeveral
UnderlyingfetchXHR (browser) / http (Node)
Built-in retryβœ“Needs axios-retry
Built-in timeoutβœ“ (10s default)βœ“
Interceptorshooksinterceptors
Node.js support22+All versions
Schema validationβœ“ (Standard Schema)βœ—

Choose ky when: modern browser project, Node 22+, want a smaller bundle, like fetch but want less boilerplate.

Choose axios when: need legacy Node support, existing large codebase using axios, need XHR-specific features.

Summary

ky has a clear purpose: solve fetch’s pain points without adding complexity. Retry, timeout, hooks, JSON shorthand β€” these are things every fetch-based project eventually implements itself. ky bundles it all in 4KB.

If your project uses axios but doesn’t actually use anything axios-specific, switching to ky cuts bundle size by two-thirds, and the API looks almost identical.

References