Compare
How liaise compares
A harness in the liaise repo runs axios, ky, ofetch and liaise through 10 failure scenarios against a local server, and records what the calling code gets back. Each library runs every scenario twice: set up the way its own docs describe, and out of the box. The harness also measures bundle size and request overhead, with plain fetch as the baseline. How it compares has the rules that keep it fair.
Measured on in Node 22.18.0 on darwin arm64, against axios 1.20.0, ky 2.1.0, ofetch 1.5.1 and liaise 5.3.0. Plain fetch, the baseline, is Node's own.
This run measured liaise 5.3.0; the current release is 5.3.1.
Other libraries change, so this is a dated snapshot. Every scenario runs in Node. In browsers axios uses XHR (its adapters), so its results there can differ.
Rerun it from the repo root:
npm run build && cd compare && npm install && npm run compareIf you maintain one of these libraries and think its setup is unfair, a pull request is welcome: each library's setup is one file in compare/contenders.
The scenarios
- no throwThe call didn't throw. The text says what happened.
- throwsThe call threw.
- your codeNeeded hand-written code beyond the library's docs.
- no optionThe library has no built-in option for this.
Select a cell to see how that library was set up, with links to the docs each option comes from. A scenario's name links to the page on how liaise handles it. Every note and the code behind it: compare/results.md.
With each library's documented setup. Only options and patterns from each library's own docs. Code beyond the docs, where a scenario can't be done otherwise, is marked.
Server answers 500
- axios
- ky
- ofetch
- liaise
Server unreachable
- axios
- ky
- ofetch
- liaise
Server never answers
- axios
- ky
- ofetch
- liaise
200 with broken JSON
- axios
- ky
- ofetch
- liaise
204 with no body, on a JSON call
- axios
- ky
- ofetch
- liaise
Search as you type: which results stay on screen
- axios
- ky
- ofetch
- liaise
Five requests get a 401 at once
- axios
- ky
- ofetch
- liaise
Slow 503s, 3 s deadline, 3 retries: when does the caller hear back
- axios
- ky
- ofetch
- liaise
Path param is undefined
- axios
- ky
- ofetch
- liaise
Response is missing a field the type promises
- axios
- ky
- ofetch
- liaise
Cells that needed hand-written code: axios 3, ofetch 3, ky 2 and liaise 1.
liaise needed it only in “Five requests get a 401 at once”. No liaise cell throws.
liaise is the only one that refuses an undefined path param before sending the request.
Milliseconds are shown only where timing is the point.
axios Documented setup getJson
timeout: 3000 (axios README.md#handling-timeouts); responseType: 'json' + transitional.silentJSONParsing: false (axios README.md#request-config)
axios Documented setup getUser
This call sets no option of its own.
axios Documented setup search
hand-written: abort the previous call with signal + AbortController (axios README.md#abortcontroller)
axios Documented setup getWithAuth
hand-written: refresh in a response interceptor (axios README.md#interceptors), one shared refresh promise
axios Documented setup getWithDeadline
hand-written: loop of 4 attempts under one AbortSignal.timeout(3000) (axios README.md#abortcontroller)
axios Documented setup getValidated
no built-in option
ky Documented setup getJson
timeout: 3000 (ky readme.md#timeout); parseJson handles an empty body (ky readme.md#parsejson)
ky Documented setup getUser
This call sets no option of its own.
ky Documented setup search
hand-written: abort the previous call with signal + AbortController (ky readme.md#cancellation)
ky Documented setup getWithAuth
hand-written: one shared refresh promise in beforeRetry (readme FAQ: token refresh, ky readme.md#how-do-i-implement-token-refresh-on-401-responses); FAQ default: up to 2 retries
ky Documented setup getWithDeadline
timeout: 3000, totalTimeout: 3000, retry: { limit: 3 } (ky readme.md#totaltimeout, ky readme.md#retry)
ky Documented setup getValidated
.json(schema), Standard Schema (ky readme.md#kyinput-options)
ofetch Documented setup getJson
timeout: 3000 (ofetch README.md#-timeout); parseResponse: JSON.parse (ofetch README.md#-parsing-response)
ofetch Documented setup getUser
This call sets no option of its own.
ofetch Documented setup search
hand-written: abort the previous call with signal + AbortController (MDN AbortController); signal is a fetch option passed through; the ofetch README has no cancellation section
ofetch Documented setup getWithAuth
hand-written: refresh in onResponseError (ofetch README.md#onresponseerror-request-options-response-) + retry: 1, retryStatusCodes: [401] (ofetch README.md#-auto-retry), one shared refresh promise
ofetch Documented setup getWithDeadline
retry: 3 (ofetch README.md#-auto-retry); hand-written: overall deadline via signal: AbortSignal.timeout(3000) (MDN AbortSignal/timeout_static). ofetch's timeout is per attempt; it has no overall-deadline option (ofetch README.md#-timeout)
ofetch Documented setup getValidated
no built-in option
liaise Documented setup getJson
timeout: 3000 (iremlopsum.github.io/liaise/guide/cancelling-deadlines-and-stale-requests/); responseType: 'none' declared on the 204 endpoint; liaise has no option for an endpoint that answers JSON or an empty body (iremlopsum.github.io/liaise/guide/reading-responses/)
liaise Documented setup getUser
This call sets no option of its own.
liaise Documented setup search
dedupe: true (iremlopsum.github.io/liaise/guide/cancelling-deadlines-and-stale-requests/)
liaise Documented setup getWithAuth
hand-written: auth middleware from the auth recipe (iremlopsum.github.io/liaise/recipes/add-an-auth-header-and-refresh-the-token-on-a-401/); share: true on refresh replaces the shared refresh promise
liaise Documented setup getWithDeadline
timeout: 3000 (iremlopsum.github.io/liaise/guide/cancelling-deadlines-and-stale-requests/) + retryMiddleware({ max: 3 }) (iremlopsum.github.io/liaise/guide/retries-caching-and-logging/)
liaise Documented setup getValidated
schema, Standard Schema (iremlopsum.github.io/liaise/guide/validating-responses/)
Out of the box. Each library called the most obvious way, with no options set.
Server answers 500
- axios
- ky
- ofetch
- liaise
Server unreachable
- axios
- ky
- ofetch
- liaise
Server never answers
- axios
- ky
- ofetch
- liaise
200 with broken JSON
- axios
- ky
- ofetch
- liaise
204 with no body, on a JSON call
- axios
- ky
- ofetch
- liaise
Search as you type: which results stay on screen
- axios
- ky
- ofetch
- liaise
Five requests get a 401 at once
- axios
- ky
- ofetch
- liaise
Slow 503s, 3 s deadline, 3 retries: when does the caller hear back
- axios
- ky
- ofetch
- liaise
Path param is undefined
- axios
- ky
- ofetch
- liaise
Response is missing a field the type promises
- axios
- ky
- ofetch
- liaise
Cells that needed hand-written code: axios 1, ky 1, ofetch 1 and liaise 1.
A 204 and a slow server are normal. An error, or still waiting, in those rows is a failure.
axios Out of the box getJson
Called the most obvious way, with no options set.
axios Out of the box getUser
Called the most obvious way, with no options set.
axios Out of the box search
Called the most obvious way, with no options set.
axios Out of the box getWithAuth
hand-written: naive refresh on 401, retry once
axios Out of the box getWithDeadline
Called the most obvious way, with no options set.
axios Out of the box getValidated
Called the most obvious way, with no options set.
ky Out of the box getJson
Called the most obvious way, with no options set.
ky Out of the box getUser
Called the most obvious way, with no options set.
ky Out of the box search
Called the most obvious way, with no options set.
ky Out of the box getWithAuth
hand-written: naive refresh on 401, retry once
ky Out of the box getWithDeadline
Called the most obvious way, with no options set.
ky Out of the box getValidated
Called the most obvious way, with no options set.
ofetch Out of the box getJson
Called the most obvious way, with no options set.
ofetch Out of the box getUser
Called the most obvious way, with no options set.
ofetch Out of the box search
Called the most obvious way, with no options set.
ofetch Out of the box getWithAuth
hand-written: naive refresh on 401, retry once
ofetch Out of the box getWithDeadline
Called the most obvious way, with no options set.
ofetch Out of the box getValidated
Called the most obvious way, with no options set.
liaise Out of the box getJson
Called the most obvious way, with no options set.
liaise Out of the box getUser
Called the most obvious way, with no options set.
liaise Out of the box search
Called the most obvious way, with no options set.
liaise Out of the box getWithAuth
hand-written: same refresh middleware, without share
liaise Out of the box getWithDeadline
Called the most obvious way, with no options set.
liaise Out of the box getValidated
Called the most obvious way, with no options set.
Size
One JSON GET per library, as a minified ES2020 ESM bundle for a browser, gzip and brotli compressed. For liaise that bundle holds the REST client only; GraphQL, polling and the middleware are separate imports, and Exports gives the size of each.
Plain fetch is the baseline, not a library: it is built into the runtime, so its row is the call site only.
| Library | gzip (kB) | brotli (kB) |
|---|---|---|
| plain fetch (baseline) | 0.1 | 0.1 |
| axios | 19.1 | 17.3 |
| ky | 9.6 | 8.5 |
| ofetch | 4.0 | 3.6 |
| liaise | 7.0 | 6.4 |
| liaise + retryMiddleware | 7.6 | 6.9 |
Gzipped or brotli-compressed, liaise is larger than ofetch and smaller than ky and axios.
Requests per second
Median (min–max) of 10 interleaved rounds of 2,000 calls each, after 2,000 warm-up calls per library. Measured on localhost, keep-alive as each library defaults; a real network adds milliseconds per request, this measures microseconds. Differences under about 5% are noise.
Plain fetch is the baseline: what each library adds to a request is measured against it.
| Library | One at a time | 50 in flight |
|---|---|---|
| plain fetch (baseline) | 16,550 (14,668–17,117) | 19,496 (17,587–20,150) |
| axios | 13,540 (11,196–13,872) | 15,556 (14,322–16,090) |
| ky | 13,721 (12,672–14,694) | 15,895 (15,217–16,246) |
| ofetch | 16,157 (15,632–16,639) | 18,840 (18,412–19,610) |
| liaise | 16,131 (15,574–16,791) | 18,797 (17,854–19,341) |
One request at a time, liaise ties plain fetch and ofetch; ky handles about 15% fewer requests per second than liaise, axios about 16% fewer.
With 50 requests in flight, liaise ties plain fetch and ofetch; ky handles about 15% fewer requests per second than liaise, axios about 17% fewer.
Where liaise loses
- No default timeout. Out of the box, a liaise call to a server that never answers is still waiting after 15s. Only ky times out by default. Set
timeoutand the call ends with “error result (timeout)”, as in the documented setup. - A 204 on a JSON call is a parse error. Out of the box, liaise gives “error result (parse)” for a 204 with no body on a JSON call; ky throws; axios and ofetch resolve. An endpoint that answers 204 needs
responseType: 'none'. - One endpoint, JSON or an empty body. liaise has no option for an endpoint that answers JSON or an empty body, as the harness notes. In ky's documented setup, parseJson handles an empty body.
- No retries out of the box. With no options set, ky made 3 and ofetch 2 attempts at the slow 503s; liaise made 1. liaise retries with
retryMiddleware. - Larger than ofetch. Gzipped, liaise is 7.0 kB; ofetch 4.0 kB.
Closest alternative: ky
Apart from throwing where liaise returns an error result, ky's documented setup differs from liaise's in 2 of the 10 rows, fewer than any other library here:
| Scenario | ky | liaise |
|---|---|---|
| Search as you type: which results stay on screen | shows "rea"your code | shows "rea"no throw |
| Path param is undefined | requests /s/users/undefinedno throw | refused before sending: error result (network)no throw |
Out of the box, only ky times out, and ky and ofetch retry.
ky is 9.6 kB gzipped, liaise 7.0 kB.
Pick something else when…
- You need a normalized GraphQL cache, optimistic updates or subscriptions. Use Apollo Client or urql. Apollo's caching overview and urql's Graphcache docs explain how each one caches.
- You want failed calls to throw. ky throws an
HTTPErrorfor a response outside 2xx unless you turnthrowHttpErrorsoff. - You want caching and refetching in your UI. Use TanStack Query, with liaise underneath: a query function can be any function that returns a promise. The TanStack Query recipe shows the two together.
- You make two or three calls. Plain
fetchis fine.
When it fits, and when it doesn't has the longer version.