Fix: ky Not Working — Requests Failing, Hooks Not Firing, or Retry Not Working
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 401Or 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 attemptsWhy 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 anHTTPErrorfor 4xx and 5xx status codes. You must catch these errors or usethrowHttpErrors: false. - Hooks are set on instances, not individual requests —
ky.create()sets hooks for all requests made with that instance. Passing hooks to a singleky.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 dynamicimport()or switch to ESM ("type": "module"in package.json).
Fix 1: Basic Usage
npm install kyimport 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 itFix 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 HTTPErrorStill 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.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: CodeMirror Not Working — Editor Not Rendering, Extensions Not Loading, or React State Out of Sync
How to fix CodeMirror 6 issues — basic setup, language and theme extensions, React integration, vim mode, collaborative editing, custom keybindings, and read-only mode.
Fix: GSAP Not Working — Animations Not Playing, ScrollTrigger Not Firing, or React Cleanup Issues
How to fix GSAP animation issues — timeline and tween basics, ScrollTrigger setup, React useGSAP hook, cleanup and context, SplitText, stagger animations, and Next.js integration.
Fix: i18next Not Working — Translations Missing, Language Not Switching, or Namespace Errors
How to fix i18next issues — react-i18next setup, translation file loading, namespace configuration, language detection, interpolation, pluralization, and Next.js integration.
Fix: Mapbox GL JS Not Working — Map Not Rendering, Markers Missing, or Access Token Invalid
How to fix Mapbox GL JS issues — access token setup, React integration with react-map-gl, markers and popups, custom layers, geocoding, directions, and Next.js configuration.