Fix: ky Not Working — Requests Failing, Hooks Not Firing, or Retry Not Working
Part of: React & Frontend Errors
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.
Because ky sits on top of fetch, every “ky doesn’t work” report falls into one of two buckets: a ky API mismatch (you passed body instead of json, or expected fetch-style behavior where ky throws), or an underlying fetch environment problem (no polyfill, Node version mismatch, mixed CommonJS/ESM). The two look identical from the call site but have completely different fixes. Diagnosing requires checking ky’s behavior in isolation against the runtime environment, not just retrying the same call with different options.
- 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).
A third class of failure is the boundary between ky’s retry/timeout settings and the rest of your stack. If the upstream API has its own rate limit (say, 60 requests per minute) and you naively set retry: { limit: 5 }, a transient 429 cascades into five more requests that all hit the same limit. The result is a polite client that DDoSes its own backend during a partial outage. Tuning retry budgets, backoff jitter, and per-endpoint timeout limits is operations work, not configuration work, and the defaults are starting points, not final answers.
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 HTTPErrorProduction Incident: The Polyfill Mismatch That Took Down Checkout
The incident pattern: ky requests work in local dev and on staging. After a Node version bump from 18 to 20, or after migrating the SSR layer to Edge runtime, a subset of endpoints start failing with cryptic TypeError: Failed to fetch or Headers is not a constructor errors. The failures are not deterministic — they happen on cold starts, behind a particular load balancer, or only on certain serverless regions.
The root cause is fetch surface drift. Node 18 ships a native fetch but its Headers and Request constructors are not identical to the WHATWG spec; certain helpers that worked in node-fetch (which apps used to polyfill ky’s dependency) throw on the native implementation. Edge runtimes implement a different subset again. Your bundler might have stripped a polyfill that was working around this; your next/server runtime might inject its own. The result is that ky calls a constructor that exists in some environments and not in others.
The blast radius is per-endpoint, but in a checkout flow that includes a payment-intent fetch, a tax-calculation fetch, and an order-creation fetch, even a 5% failure rate translates to lost revenue inside the hour. Worse, your error tracker shows the same stack on every failure, which makes it look like a single bug — when it is actually three different code paths hitting the same constructor.
Monitor 4xx and 5xx rates per endpoint, not site-wide. A spike on /api/checkout/intent while /api/products stays flat is the signal. Add a synthetic check that runs an end-to-end checkout against a sandbox payment account every five minutes. Pair that with a fast rollback path for the runtime — if Node 20 broke something, rolling back to Node 18 should be a config change, not a multi-step migration.
When you ship a runtime change, also pin ky’s behavior with a smoke test in CI: instantiate ky, make a real request to a test server, and assert that hooks fire and retries work. The test is cheap and catches polyfill drift before it reaches production.
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().
Retries hammer your own backend during partial outages — when a downstream API returns 503, naive retry: { limit: 5 } with no backoff cap produces a thundering herd. Set backoffLimit to a sane ceiling (3000-5000ms), and use jittered exponential delays via delay: (attemptCount) => 0.3 * 2 ** attemptCount * 1000 * Math.random(). Better still, add a circuit breaker upstream of ky so consecutive failures stop hitting the network entirely.
Timeouts fire too aggressively on slow mobile networks — ky’s default 10-second timeout is short for 3G or congested Wi-Fi. Real-user metrics show a long tail of legitimate requests above 8 seconds. Either lengthen the timeout per request type (uploads need more than reads) or stream where possible. Watch the relationship between timeout rate and HTTP error rate — if timeouts dominate, your timeout is the bottleneck, not your API.
AbortError rejections crash the page after navigation — when a user navigates away from a page, the cleanup controller.abort() cancels the in-flight ky request and the promise rejects. If the rejection is unhandled, React 18 surfaces it as an error overlay. Filter error.name === 'AbortError' in your catch block and ignore it silently — abort is not an error, it is intentional cancellation.
For related HTTP client issues, see Fix: TanStack Query Not Working, Fix: tRPC Not Working, Fix: Axios Network Error, and Fix: httpx 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.