Skip to content

Fix: ky Not Working — Requests Failing, Hooks Not Firing, or Retry Not Working

FixDevs ·

Quick Answer

How to fix ky HTTP client issues — instance creation, hooks (beforeRequest, afterResponse), retry configuration, timeout handling, JSON parsing, error handling, and migration from fetch or axios.

The Problem

A ky request returns an unexpected error:

import ky from 'ky';

const data = await ky.get('https://api.example.com/users').json();
// HTTPError: Request failed with status code 401

Or hooks don’t fire:

const api = ky.create({
  hooks: {
    beforeRequest: [(request) => {
      console.log('Before request');  // Never logs
    }],
  },
});

Or retry doesn’t retry:

const data = await ky.get('/api/data', { retry: 3 }).json();
// Fails immediately on 500 — no retry attempts

Why This Happens

ky is a modern HTTP client built on top of the Fetch API. It’s smaller and simpler than axios but has its own conventions:

  • ky throws on non-2xx responses by default — unlike fetch, which only rejects on network errors, ky throws an HTTPError for 4xx and 5xx status codes. You must catch these errors or use throwHttpErrors: false.
  • Hooks are set on instances, not individual requestsky.create() sets hooks for all requests made with that instance. Passing hooks to a single ky.get() call works but uses a different syntax.
  • Retry only applies to specific error types — by default, ky retries on network errors and 408/413/429/500/502/503/504 status codes. A 400 or 401 is not retried because it’s a client error that retrying won’t fix.
  • ky is ESM-only — it can’t be require()d. In Node.js with CommonJS, use dynamic import() or switch to ESM ("type": "module" in package.json).

Fix 1: Basic Usage

npm install ky
import ky from 'ky';

// GET with JSON parsing — ky.json() parses automatically
const users = await ky.get('https://api.example.com/users').json<User[]>();

// POST with JSON body
const newUser = await ky.post('https://api.example.com/users', {
  json: { name: 'Alice', email: '[email protected]' },
}).json<User>();

// PUT
await ky.put('https://api.example.com/users/123', {
  json: { name: 'Alice Updated' },
});

// DELETE
await ky.delete('https://api.example.com/users/123');

// PATCH
await ky.patch('https://api.example.com/users/123', {
  json: { role: 'admin' },
}).json<User>();

// Query parameters
const results = await ky.get('https://api.example.com/search', {
  searchParams: { q: 'typescript', page: 1, limit: 20 },
}).json();

// Form data
const formData = new FormData();
formData.append('file', fileBlob, 'photo.jpg');
await ky.post('https://api.example.com/upload', { body: formData });

// Get response headers
const response = await ky.get('https://api.example.com/data');
const rateLimit = response.headers.get('X-RateLimit-Remaining');
const data = await response.json();

Fix 2: Create Configured Instances

import ky from 'ky';

// Create a pre-configured instance — like axios.create()
const api = ky.create({
  prefixUrl: 'https://api.example.com',
  timeout: 30000,  // 30 seconds (default: 10000)
  retry: {
    limit: 3,
    methods: ['get', 'put', 'delete'],
    statusCodes: [408, 429, 500, 502, 503, 504],
    backoffLimit: 3000,
  },
  headers: {
    'Accept': 'application/json',
  },
  hooks: {
    beforeRequest: [
      (request) => {
        const token = getAuthToken();
        if (token) {
          request.headers.set('Authorization', `Bearer ${token}`);
        }
      },
    ],
    beforeRetry: [
      async ({ request, error, retryCount }) => {
        console.log(`Retry attempt ${retryCount} for ${request.url}`);

        // Refresh token on 401 before retry
        if (error instanceof ky.HTTPError && error.response.status === 401) {
          const newToken = await refreshAuthToken();
          request.headers.set('Authorization', `Bearer ${newToken}`);
        }
      },
    ],
    afterResponse: [
      async (request, options, response) => {
        if (response.status === 401) {
          // Token expired — refresh and retry
          const newToken = await refreshAuthToken();
          request.headers.set('Authorization', `Bearer ${newToken}`);
          return ky(request);  // Return a new response to replace the original
        }
      },
    ],
    beforeError: [
      async (error) => {
        // Attach response body to error for easier debugging
        const { response } = error;
        if (response) {
          const body = await response.json().catch(() => null);
          if (body?.message) {
            error.message = body.message;
          }
        }
        return error;
      },
    ],
  },
});

// Usage — prefixUrl is prepended automatically
const users = await api.get('users').json<User[]>();
const user = await api.get('users/123').json<User>();
const created = await api.post('users', { json: { name: 'Bob' } }).json<User>();

// Extend an instance with additional config
const adminApi = api.extend({
  headers: { 'X-Admin': 'true' },
  retry: { limit: 5 },
});

Fix 3: Error Handling

import ky, { HTTPError, TimeoutError } from 'ky';

async function fetchUser(id: string): Promise<User | null> {
  try {
    return await api.get(`users/${id}`).json<User>();
  } catch (error) {
    if (error instanceof HTTPError) {
      const status = error.response.status;
      const body = await error.response.json().catch(() => null);

      if (status === 404) return null;
      if (status === 401) throw new AuthError('Unauthorized');
      if (status === 429) throw new RateLimitError(body?.retryAfter);

      throw new ApiError(status, body?.message || 'API error');
    }

    if (error instanceof TimeoutError) {
      throw new ApiError(0, 'Request timed out');
    }

    throw error;  // Network error or unknown
  }
}

// Disable throwing on HTTP errors — behave like fetch
const response = await ky.get('https://api.example.com/data', {
  throwHttpErrors: false,
});

if (response.ok) {
  const data = await response.json();
} else {
  console.error(`Error: ${response.status}`);
}

// Type-safe error handling with response body
interface ApiErrorBody {
  message: string;
  code: string;
  details?: Record<string, string>;
}

async function safeRequest<T>(request: Promise<T>): Promise<{ data: T; error: null } | { data: null; error: ApiErrorBody }> {
  try {
    const data = await request;
    return { data, error: null };
  } catch (error) {
    if (error instanceof HTTPError) {
      const body = await error.response.json<ApiErrorBody>().catch(() => ({
        message: error.message,
        code: 'UNKNOWN',
      }));
      return { data: null, error: body };
    }
    return { data: null, error: { message: 'Network error', code: 'NETWORK' } };
  }
}

// Usage
const { data, error } = await safeRequest(api.get('users').json<User[]>());
if (error) {
  console.error(error.message);
} else {
  console.log(data);
}

Fix 4: Abort and Timeout

import ky from 'ky';

// Timeout per request
const data = await ky.get('https://slow-api.com/data', {
  timeout: 5000,  // 5 seconds
}).json();

// Manual abort with AbortController
const controller = new AbortController();

// Cancel after 3 seconds
setTimeout(() => controller.abort(), 3000);

try {
  const data = await ky.get('https://api.example.com/large-data', {
    signal: controller.signal,
  }).json();
} catch (error) {
  if (error.name === 'AbortError') {
    console.log('Request was cancelled');
  }
}

// Cancel on component unmount (React)
useEffect(() => {
  const controller = new AbortController();

  ky.get('https://api.example.com/data', { signal: controller.signal })
    .json()
    .then(setData)
    .catch(err => {
      if (err.name !== 'AbortError') console.error(err);
    });

  return () => controller.abort();
}, []);

Fix 5: Progress Tracking

import ky from 'ky';

// Download progress
const response = await ky.get('https://api.example.com/large-file');

const reader = response.body?.getReader();
const contentLength = Number(response.headers.get('Content-Length'));
let received = 0;

while (reader) {
  const { done, value } = await reader.read();
  if (done) break;
  received += value.length;
  const progress = contentLength ? (received / contentLength) * 100 : 0;
  console.log(`Downloaded: ${progress.toFixed(1)}%`);
}

// Upload progress (using onUploadProgress hook — not natively supported in fetch)
// For upload progress, use XMLHttpRequest or a library that wraps it

Fix 6: Migration from Axios

// Axios → ky equivalents

// axios.create({ baseURL, headers })
const api = ky.create({ prefixUrl: 'https://api.example.com', headers: {} });

// axios.get('/users', { params: { page: 1 } })
const users = await api.get('users', { searchParams: { page: 1 } }).json();

// axios.post('/users', { name: 'Alice' })
const user = await api.post('users', { json: { name: 'Alice' } }).json();

// axios.interceptors.request.use(config => { ... })
// → hooks.beforeRequest

// axios.interceptors.response.use(response => { ... })
// → hooks.afterResponse

// response.data  →  await response.json()
// error.response.data  →  await error.response.json()
// error.response.status  →  error.response.status (same)

// axios.isAxiosError(error)  →  error instanceof HTTPError

Still Not Working?

require('ky') fails — ky is ESM-only. Use import ky from 'ky' or const ky = await import('ky'). In Node.js, add "type": "module" to package.json or use .mjs extension.

Request retries but still fails — ky only retries on specific status codes (408, 429, 500, 502, 503, 504) and network errors. 400, 401, 403, 404 are not retried because they indicate client errors. To retry 401, add custom logic in beforeRetry or afterResponse hooks.

Hooks don’t execute — hooks set on ky.create() only apply to requests made with that instance. ky.get() (the default instance) doesn’t have your custom hooks. Always use your configured instance.

Response body already consumed — calling .json() twice on the same response throws. Store the result: const data = await response.json(). In error hooks, clone the response before reading: await error.response.clone().json().

For related HTTP client issues, see Fix: TanStack Query Not Working and Fix: tRPC Not Working.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles