Home | Send Feedback | Share on Bluesky |

Ky - elegant fetch

Published: 10. August 2019  •  Updated: 20. March 2026  •  javascript

In this article, we take a closer look at Ky, a tiny HTTP client built on top of the Fetch API. Ky keeps the Fetch programming model, but adds a few carefully chosen conveniences that make day-to-day HTTP code shorter and easier to reason about.

Ky targets modern browsers and also runs on Node.js, Bun, and Deno. You add it to an npm-managed project with npm install ky.

The examples below focus on the features that are most useful in everyday applications.

Basics

Let's look at a fetch example that sends a GET request to a server for a text response.

  let response = await fetch('http://localhost:8080/simple-get');
  let body = await response.text();

main.js

The Ky version looks almost identical.

  response = await ky('http://localhost:8080/simple-get');
  body = await response.text();

main.js

ky accepts the same parameters as fetch. The first parameter is either a URL string or a Request object, and the second parameter is an object containing custom settings. The return value in both cases is a Promise that resolves to a Response object.

The surface API is intentionally familiar, but Ky adds a few useful defaults.

We will look at all of these behaviors in more detail later in this post.


Both fetch() and ky() return a Promise that resolves to a Response object. To extract the body, you call one of the body methods such as text(), json(), formData(), blob(), or arrayBuffer(). With fetch(), these methods run asynchronously and return a Promise, so you wait twice: first for the response and then for the body extraction method.

Ky exposes these body methods directly on the response promise. That lets us rewrite the example in one line and only wait once.

body = await ky.get('http://localhost:8080/simple-get').text();

This is not only syntactic sugar. Ky also sets a matching Accept header automatically.

In runtimes that support Response.prototype.bytes(), Ky also exposes .bytes() and returns a Uint8Array.

These shortcut methods overwrite a custom Accept header. The following example sends Accept: */* instead of the header we configured.

body = await ky.get('http://localhost:8080/simple-get', { headers: { Accept: 'application/octet-stream' } }).arrayBuffer();

If you want to keep a custom Accept header, use the long form and wait twice.

  response = await ky.get('http://localhost:8080/simple-get', { headers: { Accept: 'application/octet-stream' } });
  body = await response.arrayBuffer();

main.js

Method shortcuts

When using the Fetch API to send a request other than GET, you must specify the method option.

fetch('....', { method: 'POST', ...});
fetch('....', { method: 'PUT', ...});

Ky supports this as well.

ky('....', { method: 'POST', ...});
ky('....', { method: 'PUT', ...});

To avoid repeating the method option, Ky ships with method shortcuts.

ky.get(input, [options])
ky.post(input, [options])
ky.put(input, [options])
ky.patch(input, [options])
ky.head(input, [options])
ky.delete(input, [options])

All of these methods set the correct method option (GET, POST, PUT, PATCH, HEAD, DELETE).

Notice that ky.get(...) and ky(...) are equivalent because GET is the default method.

TypeScript

The examples in this post are written in JavaScript. If your code is written in TypeScript, you can add generics to ky(), all method shortcuts, and json().

const user = await ky<User>('/api/users/2').json();
// or
const user = await ky('/api/users/3').json<User>();

The two calls are equivalent. In both cases, user has the type User.

If you don't specify a generic type parameter, Ky defaults to unknown.

const user = await ky('/api/users/1').json();

user is of type unknown.

Posting data

Posting JSON data with fetch requires a few lines of code. We have to specify the Content-Type header, the method option, and convert the object we want to send to a JSON string. If we expect a JSON response, we also have to check whether the response is okay and then extract the response body.

  let response = await fetch('http://localhost:8080/simple-post', {
    method: 'POST',
    body: JSON.stringify({ value: "hello world" }),
    headers: {
      'content-type': 'application/json'
    }
  });

  if (response.ok) {
    const body = await response.json();
    console.log(body);
  }

main.js

With Ky, this becomes a one-liner. Thanks to the post() shortcut method, we can omit the method option. With the json option, we can pass the object directly without converting it first. Ky calls JSON.stringify() internally, writes the result to the request body, and sets the Content-Type header to application/json. Finally, we can use the exposed json() method on the response promise and wait for the response and body parsing at the same time.

  const body = await ky.post('http://localhost:8080/simple-post', { json: { value: "hello world" } }).json();
  console.log(body);

main.js


Sending form data is identical to the Fetch API. Create a FormData object and assign it to the body option. Ky automatically sets the correct Content-Type header for the multipart request.

  const formData = new FormData();
  formData.append('value1', '10');
  formData.append('value2', 'ten');

  await ky.post('http://localhost:8080/multipart-post', {
    body: formData
  });

main.js

If you want to send a request with the Content-Type: application/x-www-form-urlencoded header, create a URLSearchParams object and assign that to the body option.

  const searchParams = new URLSearchParams();
  searchParams.set('value1', '10');
  searchParams.set('value2', 'ten');

  await ky.post('http://localhost:8080/formurlencoded-post', {
    body: searchParams
  });

main.js

Download Progress

Ky lets your application register a download progress handler that is called while a resource is being fetched.

  await ky.get('http://localhost:8080/download', {
    onDownloadProgress: (progress, chunk) => {
      console.log(`${progress.percent * 100}% - ${progress.transferredBytes} of ${progress.totalBytes} bytes`);
    }
  });

main.js

The event handler receives a progress object and a chunk argument. Before Ky starts the download, it calls the handler with progress.percent === 0 and progress.transferredBytes === 0. In this first call, chunk is an empty Uint8Array. In later calls, chunk contains only the bytes transferred since the previous onDownloadProgress call, not the full response.

The progress object contains percent, transferredBytes, and totalBytes. If the server does not send a usable Content-Length header, Ky cannot determine the total size, and totalBytes will be 0.

Error Handling

One important difference from the Fetch API is that Ky throws an HTTPError when the response status code is outside the 200-299 range.

The URL /notfound in this demo application returns a 404 status code. Fetch treats this as a normal response and simply sets response.ok to false. Fetch only throws when the request itself fails, for example because of a network problem.

  let response = await fetch('http://localhost:8080/notfound');
  console.log("response.ok = ", response.ok);

main.js

Ky throws for network failures as well, but it also throws for non-2xx HTTP responses.

  try {
    response = await ky.get('http://localhost:8080/notfound');
  } catch (e) {
    console.log('ky.get', e);

main.js

You can disable this behavior by setting throwHttpErrors to false. Ky then behaves more like the Fetch API and only throws for request failures.

Current Ky versions also allow throwHttpErrors to be a function if you want to throw only for specific status codes, but the boolean form is the clearest choice for most applications.

  response = await ky.get('http://localhost:8080/notfound', { throwHttpErrors: false });
  console.log("response.ok = ", response.ok);

main.js

Retry

Ky enables retries by default. It resends a failed request two times. If the third attempt fails, Ky throws an exception.

You can change the number of retry attempts with the retry option.

  response = await ky.get('http://localhost:8080/retry', { retry: 5 }).text();

main.js

You can turn the feature off by setting the option to 0.

    const response = await ky.get('http://localhost:8080/retry-test', { retry: 0 }).text();

main.js

You can pass an object with more options instead of a number to fine-tune the retry behavior. If you pass an object, you specify the number of retry attempts with the limit option.

  response = await ky.get('http://localhost:8080/retry', {
    retry: {
      limit: 10,
      methods: ['get'],
      afterStatusCodes: [429]
    }
  }).text();

main.js

Ky, by default, only retries requests in case the server responds with one of these status codes: 408, 413, 429, 500, 502, 503, or 504. You can override this behavior with the statusCodes option.

Also, it only retries requests for the get, put, head, delete, options, and trace methods. You can change this with the methods option.

The retry configuration also supports afterStatusCodes, which defaults to 413, 429, and 503. When Ky receives one of these responses, it checks the Retry-After header and waits for the indicated time before retrying. If Retry-After is missing, Ky falls back to the non-standard RateLimit-Reset header and then to its normal retry delay.

The normal retry behavior is configured with backoffLimit and delay. The defaults are undefined for backoffLimit and attemptCount => 0.3 * (2 ** (attemptCount - 1)) * 1000 for delay. The backoffLimit option is the upper limit of the delay per retry in milliseconds.

The delay option is a function that calculates the delay for each retry. The function receives the attempt count as a parameter and returns the delay in milliseconds.

Current Ky versions also support jitter, retryOnTimeout, and shouldRetry, which are useful when you want to spread retries across clients, retry timeouts deliberately, or centralize custom retry rules.

Here are the wait times for the first few attempts with the default delay function:

// after 1st attempt --> wait 0.3 seconds
// after 2nd attempt --> wait 0.6 seconds
// after 3rd attempt --> wait 1.2 seconds
// after 4th attempt --> wait 2.4 seconds
// ....

Timeout

Timeout is another built-in Ky feature. By default, every ky call throws a TimeoutError if the server does not respond within 10 seconds. You can change the timeout with the timeout option, which expects a value in milliseconds.

  console.log('ky, timeout 1s');
  try {
    console.log('request ', Date.now());
    response = await ky.get('http://localhost:8080/timeout', { timeout: 1000 });
  } catch (e) {
    console.log('response', Date.now());
    console.log(e);

main.js

If you want to turn off the timeout and wait as long as necessary for a response, assign false to the timeout option.

  response = await ky.get('http://localhost:8080/timeout', { timeout: false });

main.js

Abort

Request cancellation is part of the Fetch API, so you use it with Ky in exactly the same way. Create an AbortController, pass controller.signal to the request, and call controller.abort() when you want to cancel it.

  const controller = new AbortController();

  setTimeout(() => {
    console.log('abort', Date.now());
    controller.abort();
  }, 2500);

  try {
    console.log('request', Date.now());
    const body = await ky.get('http://localhost:8080/timeout', { signal: controller.signal }).text();
  } catch (error) {
    console.log('exception', Date.now());
    if (error.name === 'AbortError') {
      console.log('Fetch aborted');
    } else {
      console.error('Fetch error:', error);
    }
  }

main.js

Hooks

Hooks let you plug custom logic into the request and response lifecycle.

You configure hooks by creating an object with properties such as beforeRequest, beforeRetry, beforeError, and afterResponse, and then assigning it to the hooks option. Each property expects an array of functions.

Current Ky versions pass a single state object into every hook. For example, beforeRequest receives an object that contains the normalized request, the resolved options, and the retry count. afterResponse receives a state object that includes the request, options, response clone, and retry count.

In the example below, we add one additional header before Ky sends the request and then replace the response with a new Response object.

  const hooks = {
    beforeRequest: [
      ({ request }) => {
        console.log('before request');
        request.headers.set('x-api-key', '1111');
      }
    ],
    afterResponse: [
      ({ response }) => {
        console.log('after response');
        console.log(response);
        // return different response
        return new Response('{"value": "something else"}', { status: 200 });
      }
    ]
  };


  const body = await ky.get('http://localhost:8080/simple-get', { hooks }).json();
  console.log('body: ', body);

main.js

beforeRetry runs immediately before a retry attempt and lets you adjust the outgoing request. beforeError runs right before Ky throws an error and lets you rewrite or enrich that error.

afterResponse can also return ky.retry(...) when you want to trigger a retry based on the response body rather than the HTTP status code.

Custom default instances

One especially useful option for larger applications is baseUrl. It lets you define a base URL once and resolve relative request paths against it.

Without baseUrl

const body = await ky.get('http://localhost:8080/simple-get').text();

With baseUrl

  let body = await ky.get('simple-get', { baseUrl: 'http://localhost:8080/' }).text();

main.js

For most use cases, baseUrl is the right choice. Ky also supports prefix for situations where you want leading slashes in request paths to be treated like relative input.


This becomes much more convenient when combined with Ky instances. Ky provides ky.extend(defaultOptions) and ky.create(defaultOptions) for creating instances with custom defaults.

At application startup, you can create a custom Ky instance.

  const customKy = ky.create({ baseUrl: 'http://localhost:8080/' });

main.js

In the rest of your application, you use that instance without repeating the base URL.

  body = await customKy('simple-get').text();

main.js


extend() inherits defaults from its parent, while create() starts a fresh instance.

In the example below, we extend the custom instance and add a request header. The resulting instance keeps the inherited baseUrl and adds the new header.

  const customApiKy = customKy.extend({ headers: { 'x-api-key': '1111' } });
  body = await customApiKy('simple-get').text();

main.js


Ky is a small library, but it solves several of the rough edges that tend to show up when you use the Fetch API directly: repetitive JSON handling, status-code checks, retries, timeouts, and reusable defaults.

Check out the project page for more information: https://github.com/sindresorhus/ky

You can find the source code for all the examples in this blog post on GitHub: https://github.com/ralscha/blog2019/tree/master/ky