In this article, we are going to take a closer look at ky, a JavaScript HTTP client library for the browser on top of the Fetch API. ky adds convenience functions and new features to the existing Fetch API feature set.
ky targets modern browsers and depends on the Fetch API. If you want to use ky on older browsers you may need to add the Fetch polyfill
You add ky to an npm managed project with npm install ky
.
In the following article, I'm going to show you examples of all new features and convenience functions of ky.
Basics ¶
Let's first look at a fetch example that sends a GET request to a server to get a text response.
let response = await fetch('http://localhost:8080/simple-get');
let body = await response.text();
If we do the same with ky we write the following code.
response = await ky('http://localhost:8080/simple-get');
body = await response.text();
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.
This does not look very exciting; it's almost the same code; we only replaced fetch
with ky
.
However, under the hood, there are a few changes.
- ky implicitly set the
credentials
option tosame-origin
. This is the default according to the Fetch API specification, but not all browsers implemented this correctly. - The ky request times out after 10 seconds if the server does not send a response during that time
- ky automatically resends the request once if the first attempt fails with an error.
- ky throws an exception when the server does not send a response status between 200 and 299
We are going to look at all these changes in a bit more detail later in this blog post.
Both, fetch()
and ky()
return a Promise that resolves to a Response
object. To extract the
body an application has to call one of these methods: text()
, json()
, formData()
, blob()
and arrayBuffer()
.
These methods also run asynchronously and return a Promise, so we have to wait two times, first on the response and then
on the body extraction.
ky simplifies this process by exposing these 5 body methods through the Response Promise. With this improvement, we can rewrite the example from above in one line and only have to wait once.
body = await ky.get('http://localhost:8080/simple-get').text();
This is not only syntax sugar; it also has a practical side effect. ky automatically sets the proper Accept
header
in the request.
.json()
:Accept: application/json
.text()
:Accept: text/*
.formData()
:Accept: multipart/form-data
.blob()
:Accept: */*
.arrayBuffer()
:Accept: */*
Notice that these 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 send a custom Accept
request header, you must use the long-form and await twice.
response = await ky.get('http://localhost:8080/simple-get', { headers: { Accept: 'application/octet-stream' } });
body = await response.arrayBuffer();
Method shortcuts ¶
When you want to send a request other than GET with the Fetch API, you have to specify the method
option.
fetch('....', { method: 'POST', ...});
fetch('....', { method: 'PUT', ...});
ky supports this as well
ky('....', { method: 'POST', ...});
ky('....', { method: 'PUT', ...});
It's a bit cumbersome to always specify the method, therefore ky introduces shortcut methods, to make this a bit more convenient.
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 these methods assign the proper value to the method
option ('GET'
, 'POST'
, 'PUT'
, 'PATCH'
, 'HEAD'
, 'DELETE'
).
Notice that ky.get(...)
and ky(...)
are equivalent, because 'GET'
is the default value for the method
option. In my humble opinion
ky.get
makes the code a bit more readable, because you don't have to know that GET is the default.
Posting data ¶
Posting JSON data with fetch requires us to write a few lines of code. We have to specify the Content-Type
header and the method
option, and we have to convert the object we want to send to a JSON string. If we expect a JSON response, we then have to check if the response was okay and 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);
}
With ky this becomes a one-liner. Thanks to the post()
shortcut method, we can omit the method
option.
With the json
option, a new option introduced by ky, we can directly pass the object, without converting it to a JSON string first.
ky internally converts the object with stringify
and assigns the string to the body
option. ky also
implicitly sets the Content-Type
header to application/json
. Lastly, we
can take advantage of the exposed json()
method of the Response Promise to wait on the response and the
body extraction method at the same time.
const body = await ky.post('http://localhost:8080/simple-post', { json: { value: "hello world" } }).json();
console.log(body);
Sending form data is identical to fetch, create a FormData
object, and assign it to the body
option.
Unlike fetch, ky automatically set the Content-Type
header to multipart/form-data
.
const formData = new FormData();
formData.append('value1', '10');
formData.append('value2', 'ten');
await ky.post('http://localhost:8080/multipart-post', {
body: formData
});
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
});
Download Progress ¶
ky allows your application to install a download progress event handler that is regularly called when ky fetches a resource and informs your application about the current progress (percent, transferred bytes).
await ky.get('http://localhost:8080/download', {
onDownloadProgress: (progress, chunk) => {
console.log(`${progress.percent * 100}% - ${progress.transferredBytes} of ${progress.totalBytes} bytes`);
}
});
The event handler receives a progress and chunk argument. Before ky starts the download it calls your event handler with progress.percent === 0
and progress.transferredBytes === 0
.
In this very first call, the chunk
parameter is an empty Uint8Array
. In subsequent calls chunk
holds an Uint8Array
with the transferred bytes since the last onDownloadProgress
call, not the full download.
The progress
object contains the following elements: percent
, transferredBytes
and totalBytes
. If the server does not send a proper Content-Length
header, it's not
possible for ky to figure out the total length; in that case, totalBytes
will be 0.
Error Handling ¶
One significant difference to fetch is that ky throws an exception (HTTPError) if the response status code is not between 200 and 299.
In this demo application, the URL /notfound
returns a 404 status code. Fetch treats this as a
normal response, does not throw an exception but sets the response.ok
property to false.
Fetch only throws exceptions when the request fails because of a network issue (server down, domain invalid, ...).
let response = await fetch('http://localhost:8080/notfound');
console.log("response.ok = ", response.ok);
On the other hand, ky throws an HTTPError
exception not only for network issues but also for each response where the status code is
not between 200 and 299.
try {
response = await ky.get('http://localhost:8080/notfound');
} catch (e) {
console.log('ky.get', e);
}
You can disable this feature by setting the throwHttpErrors
option to false
. ky then behaves like
the Fetch API and only throws exceptions for network issues.
response = await ky.get('http://localhost:8080/notfound', { throwHttpErrors: false });
console.log("response.ok = ", response.ok);
Retry ¶
Retry is a feature of ky. By default, this feature is enabled, and ky resends a failed request a second time. If the second attempt fails too, ky throws an exception.
ky sends by default retry requests in case the server responds with one of these status codes: 408, 413, 429, 500, 502, 503, 504.
Also, by default, only for the GET, PUT, HEAD, DELETE, OPTIONS, and TRACE methods.
Both the status codes and methods can be changed with methods
, statusCodes
and afterStatusCodes
.
The option statusCodes
specifies the status codes allowed to retry and the afterStatusCodes
option specifies the
status codes allowed to retry with Retry-After header.
You can change the number of retry attempts with the retry
option.
response = await ky.get('http://localhost:8080/retry', { retry: 5 }).text();
You can disable the feature by setting the option to 1.
const response = await ky.get('http://localhost:8080/retry-test', { retry: 1 }).text();
If you set other retry options, 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();
Retry respects the Retry-After
response header and waits the specified amount of time until it resends a failed request.
With maxRetryAfter
, you control the behavior of ky when processing the Retry-After response header.
If maxRetryAfter
is set to undefined
, it will use options.timeout
. If Retry-After header is greater than maxRetryAfter
, it will cancel the request.
If the server does not send a Retry-After
header, ky calculates the retry delay with the following formula:
delay = 0.3 * (2 ^ (retryCount - 1)) * 1000
// 1. attempt --> wait 0.3 seconds
// 2. attempt --> wait 0.6 seconds
// 3. attempt --> wait 1.2 seconds
// 4. attempt --> wait 2.4 seconds
// ....
Timeout ¶
Timeout is another new feature of ky. This feature, like the previous retry feature, is enabled by default, and every ky
call will throw a TimeoutError
exception if the server does not respond for 10 seconds.
You can change the timeout with the timeout
option, which expects the timeout 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);
}
If you want to disable the timeout and wait as long as possible for a response,
assign false
to the timeout
option. By disabling the timeout, you get the behavior of the native Fetch API.
response = await ky.get('http://localhost:8080/timeout', { timeout: false });
Abort ¶
Aborting a request is a feature of the Fetch API. You can use this feature with ky the
same way as you use it with fetch. Create an AbortController
, pass the controller.signal
as option to the
request call and abort the request with controller.abort()
.
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);
}
}
This is a feature that was added later to the Fetch API. You might encounter some browsers in the wild that implement the Fetch API but not the AbortController. See the Browser compatibility matrix on MDN: https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API#Browser_compatibility
Hooks ¶
With hooks, another new feature of ky, you can inject methods into the request/response lifecycle. ky supports before request and after response hooks.
To configure hooks, you create an object with the beforeRequest
and afterResponse
property and
then assign it to the option hooks
. You don't have to specify both hooks. Both properties
expect an array of functions. As parameters, the beforeRequest hooks receive the request and options objects, and
the afterResponse hooks receive the request instance, options, and response objects. Return values from beforeRequest hooks will
be ignored. When an afterResponse hook returns an object of type Response, ky overwrites the original response.
Return values of other types will be ignored.
In the example below, we add one additional header to the request before ky sends it to the server, and after the response comes back, we create a new response object and overwrite the response from the server.
const hooks = {
beforeRequest: [
(request, options) => {
console.log('before request');
options.headers.set('x-api-key', '1111');
}
],
afterResponse: [
(request, options, 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();
Custom default instances ¶
Another new option we haven't discussed so far is prefixUrl
. With this option, you can
specify a base URL that will be prepended to the input URL.
Without prefixUrl
const body = await ky.get('http://localhost:8080/simple-get').text();
With prefixUrl
let body = await ky.get('simple-get', { prefixUrl: 'http://localhost:8080' }).text();
The trailing slash is optional; ky adds it automatically if needed.
By itself, this feature does not look very useful, but in combination with another new ky feature,
this becomes very handy. ky allows you to create new ky instances that override default options.
For this purpose ky provides the ky.extend(defaultOptions)
and ky.create(defaultOptions)
methods.
Somewhere at the start of your application, you create a custom ky instance
const customKy = ky.create({ prefixUrl: 'http://localhost:8080' });
In the rest of your application, you use this instance to send requests.
body = await customKy('simple-get').text();
extend()
inherits the defaults from its parent, while create()
creates an instance with completely new default values.
In the example below, we extend the custom ky instance from above and add a custom request header. This
results in a ky instance where both options, prefixUrl
and headers
, have a default value assigned.
const customApiKy = customKy.extend({ headers: { 'x-api-key': '1111' } });
body = await customApiKy('simple-get').text();
This concludes my tutorial about ky. An elegant HTTP client built on top of the Fetch API, that adds new features and simplifies everyday tasks.
Check out the project page for more information: https://github.com/sindresorhus/ky
You find the source code for all the examples in this blog post on GitHub:
https://github.com/ralscha/blog2019/tree/master/ky