Skip to content
D
Documentation

How Ky works

concept
3 min readUpdated

Ky is an HTTP client built on Fetch that adds body shortcuts, error handling, retries, composable defaults, and lifecycle hooks.

These five ideas explain what you pass to ky, what you get back, and where to customize a request.

1. Fetch is the foundation

Pass a string, URL, or Request, with standard Fetch options plus Ky's additional options. Await the call to get a response with standard properties such as status and headers.

ts
import ky from 'ky';

const url = new URL('https://api.example.com/users');
async function main() {
	const response = await ky(url, {
		credentials: 'omit',
		headers: {'X-Client': 'dashboard'},
	});

	console.log(response.status, response.headers.get('content-type'));
}

main().catch(console.error);

This sends a GET request and logs the status and content type your service returns. The samples use absolute URLs so they also work outside the browser; point them at your service.

Keep using standard web APIs: pass FormData or a ReadableStream as body, and an AbortController's signal as signal. See files and streams and cancellation for those tasks.

2. Request and consume a response in one expression

Call a body shortcut directly on the request promise, without awaiting the response first. Ky sets an appropriate Accept header for the shortcut unless you already supplied one.

The json option handles outgoing data: it serializes the value and sets Content-Type: application/json unless your headers option specifies another content type. The .json() shortcut handles incoming data and returns unknown by default.

ts
import ky from 'ky';

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

	console.log(result);
}

main().catch(console.error);

This sends JSON and gives you the parsed response body. Inspect your service's result rather than assuming it echoes the submitted object.

Use .json<User>() when you have an application type named User. A type parameter describes the result to TypeScript; it does not validate the response. For runtime validation, pass a Standard Schema compatible validator, such as a Zod schema, to .json(schema). A rejected value throws SchemaValidationError. See send and validate JSON for a complete typed and validated example.

Other body shortcuts include .text(), .formData(), .arrayBuffer(), and .blob(). The .bytes() shortcut exists only when the runtime supports Response.prototype.bytes().

3. Errors and retries are a policy, not manual loops

By default, a non-2xx HTTP response becomes an HTTPError. Network failures and timeouts have distinct types: NetworkError and TimeoutError.

Automatic retries are bounded by retry.limit and restricted by method and failure type. The default limit is two retries; POST is not among the default retriable methods. retry.shouldRetry can override the default failure checks, but only after the limit and method checks pass. Timeouts do not trigger retries by default.

Use isHTTPError to narrow a caught error and inspect its status and pre-parsed error data.

ts
import ky, {isHTTPError} from 'ky';

async function main() {
	try {
		const result = await ky.get('https://api.example.com/users', {
			retry: {limit: 2},
			timeout: 5000,
			totalTimeout: 15_000,
		}).json();
		console.log(result);
	} catch (error) {
		if (isHTTPError(error)) {
			console.error(error.response.status, error.data);
		} else {
			throw error;
		}
	}
}

main().catch(console.error);

This request allows up to two retries for eligible failures; it does not guarantee a retry or success.

timeout gives each attempt five seconds to get a response, and body shortcuts use it as a separate body-read timeout. totalTimeout bounds the overall operation, including retries and delays, to fifteen seconds. beforeError hooks are outside that overall budget.

See retry failed requests for eligibility and delay controls, and handle request errors for error-body handling, throwHttpErrors, and schema-error classification.

4. Compose defaults into specialized instances

Use ky.create() for complete new defaults. Use .extend() to inherit and merge a parent's defaults: hooks append, headers merge, and search parameters accumulate. replaceOption replaces an inherited value instead of merging it.

ts
import ky, {replaceOption} from 'ky';

const api = ky.create({
	baseUrl: 'https://api.example.com/',
	headers: {'X-Client': 'dashboard'},
	searchParams: {locale: 'en'},
});

const usersApi = api.extend({
	headers: {'X-Feature': 'users'},
	searchParams: replaceOption({locale: 'fr'}),
});

async function main() {
	const result = await usersApi.get('users').json();
	console.log(result);
}

main().catch(console.error);

The request goes to https://api.example.com/users?locale=fr with both custom headers. The child inherits the base URL and client header, adds a feature header, and replaces the parent's search parameters. Creating the child does not change the parent.

See instances and defaults for composition and resolve request URLs for URL resolution.

5. Hooks belong to specific lifecycle stages

Hooks customize a stage rather than acting as interchangeable interceptors. For the ordinary network request path, the stages relate as follows:

mermaid
flowchart TD
	A["init: change options"] --> B["Construct Request"]
	B --> C["beforeRequest: initial request"]
	C --> D["Fetch attempt"]
	D --> E["afterResponse: read or replace response"]
	D --> F["Network failure or timeout"]
	E --> G["Check HTTP status"]
	G --> H["Return response or consume body"]
	G --> I["Retry decision"]
	F --> I
	E -->|"ky.retry()"| I
	I -->|"Retry confirmed"| J["Delay and beforeRetry"]
	J --> D
	I -->|"No retry"| K["beforeError, then throw"]
  • init synchronously changes options before request construction.
  • beforeRequest changes the initial outgoing request and runs once, with retryCount: 0.
  • beforeRetry changes a request only after a retry is confirmed.
  • afterResponse receives a response clone to read, or can return a replacement Response.
  • beforeError receives the error before it is thrown and must return an Error.
ts
import ky from 'ky';

const api = ky.extend({
	hooks: {
		beforeRequest: [({request}) => {
			request.headers.set('X-Client', 'dashboard');
		}],
		afterResponse: [({response, retryCount}) => {
			console.log('Response status:', response.status, 'Retries:', retryCount);
		}],
	},
});

async function main() {
	const result = await api.get('https://api.example.com/users').json();
	console.log(result);
}

main().catch(console.error);

This adds a header to the outgoing request and logs each response that reaches afterResponse; it leaves the response unchanged for .json() to consume.

Both beforeRequest and beforeRetry can return a Request to replace the outgoing request, or a Response to bypass the corresponding network attempt. An afterResponse hook can return ky.retry() to retry based on response content—even a successful HTTP status. That forced retry still respects retry.limit, but bypasses the method check and shouldRetry.

See request lifecycle for hook return values and authenticate requests for authentication hooks.

Next steps

Start with the quick start to make your first request, or follow send and validate JSON to give your response data a runtime contract.

Was this page helpful?

How Ky works — ky · GPT-6.1 Sol