Back to News & Insights
JavaScript October 4, 2026 · 17 min read

I Tested 11 HTTP Resilience Libraries

When I started building ffetch, I had a fairly simple goal: make HTTP requests more resilient without...

I Tested 11 HTTP Resilience Libraries

When I started building ffetch, I had a fairly simple goal: make HTTP requests more resilient without turning a fetch wrapper into a complicated piece of machinery. Retries, backoffs, circuit breaking, and the other patterns could live around a small core, with plugins adding whatever a particular application needed. I liked the elegance of that design, and keeping the library lightweight was a big part of the appeal.

The trouble started when I went back through the code looking for correctness issues. I found subtle bugs and edge cases, then more of them as I looked at what happens when you combine resilience patterns. I worked through the fixes, but the implementation kept growing, until the small library I had set out to write had become huge and complex. That was frustrating, because the simplicity was something I had deliberately designed for. But I couldn't justify keeping an elegant implementation at the expense of handling those cases correctly, so correctness won, and I accepted the complexity that came with it.

After working through that, I wanted to know what the other libraries were doing. These problems come with the resilience patterns themselves, so anyone implementing them has to deal with them somehow. How much of that complexity had other libraries managed to avoid, and how correctly did they handle the cases that had given me so much trouble? I put together a suite of 21 scenarios and ran it against eleven HTTP-resilience libraries, including ffetch, to find out.

I built scenarios covering retries and timeouts, circuit breaking, bulkheads, hedging, and request deduplication. Each library runs the scenarios its public API supports, with assertions checking the behaviour visible to the caller and the transport. The suite grew out of the bugs I found in ffetch, so its coverage reflects those concerns.

The basic cases establish that a policy does what its configuration says. Retry recovery serves two 503 responses followed by a 200 and expects exactly three attempts. Zero retries must still allow the initial request. The body replay case consumes a POST payload before returning a 503, then checks that a retry sends the same bytes or that an unsupported body is explicitly rejected.

The combination cases introduce another event while a policy is already doing something. One aborts the caller 20 ms into a 100 ms retry backoff and checks that the promise settles before the wait ends, with no later dispatch. Another cancels a caller waiting in a bulkhead queue and checks that its place becomes available to a replacement. The circuit scenarios include a success from an older request arriving after a failed probe has reopened the breaker, when that success must leave the newer cooldown intact. These cases check whether the policies still behave correctly when their state changes underneath an operation.

Most scenarios use a scripted fetch implementation that serves predefined responses, delays, headers, and streamed bodies while recording transport attempts. Vitest's fake timers let the tests advance to the exact moment of an abort or deadline, and Math.random is fixed at 0.5 to make jitter repeatable. Two integration scenarios use native fetch against a local node:http server, checking retry recovery and timeout behaviour over real sockets.

Each library is configured through a thin adapter that preserves its own results and errors. A library that returns a terminal 503 response is checked for that response, while one that rejects is checked for its error type and any documented cause. Retry counts are aligned so that "two retries" means three attempts. Where libraries differ in circuit accounting or half-open probe admission, the tests respect the declared policy and check the invariants around it.

Cancellation has an explicit contract: the caller must settle promptly, and cancelled work must stop dispatching. Queued cancellations must also release queue capacity, and deadlines must stop active work when the transport supports cancellation. These requirements can be stricter than a library's documentation, so a failure records a disagreement with the scenario's rule even when the library makes no promise about that interaction.

In the generated report, every cell links to its configuration and expected behaviour, with assertion failures and traces attached when it fails. A combination the library cannot expose through its public API is marked N/A, with the reason recorded beside it. A supported combination that fails an assertion gets a FAIL verdict. The comparison measures correctness under these scenarios, with every verdict tied to the pinned release that was tested.

The code is on GitHub. To reproduce the matrix or narrow the run to one library:

If you don't want to set up the project locally, you can also view the generated report directly at https://fetchkit.org/http-resilience/

The packages are pinned to exact versions in package.json, so a verdict is attached to a release. The selection leans toward fetch wrappers people actually install: general-purpose clients with one or two policies, packages that exist to add a single policy, and wrappers that stack several of them. And, of course, ffetch :D

| Library | Pinned version | Cells implemented | Pass | Fail | N/A | | --- | --- | --- | --- | --- | --- | | ffetch | 5.7.1 | 21 | 20 | 1 | 0 | | ky | 2.1.0 | 10 | 10 | 0 | 11 | | fetch-retry | 6.0.0 | 7 | 6 | 1 | 14 | | ofetch | 1.5.1 | 8 | 7 | 1 | 13 | | wretch | 3.0.9 | 11 | 8 | 3 | 10 | | resilient-fetch-client | 0.3.0 | 14 | 9 | 5 | 7 | | fetch-smartly | 1.0.2 | 14 | 13 | 1 | 7 | | @resili/fetch | 0.2.0-beta.1 | 20 | 15 | 5 | 1 | | fetch-resilience | 0.1.0 | 13 | 9 | 4 | 8 | | flowshield | 1.0.4 | 18 | 15 | 3 | 3 | | ts-retry-circuit | 2.1.1 | 11 | 10 | 1 | 10 |

(flowshield and ts-retry-circuit no longer publish a public GitHub repository, so those two link to their npm pages.)

Here's the full matrix. The symbols mean: ✅ passes, ❌ fails, ➖ not applicable. The header row carries the pinned version that produced each column.

| Scenario | ffetch 5.7.1 | ky 2.1.0 | fetch-retry 6.0.0 | ofetch 1.5.1 | wretch 3.0.9 | resilient-fetch-client 0.3.0 | fetch-smartly 1.0.2 | @resili/fetch 0.2.0-beta.1 | fetch-resilience 0.1.0 | flowshield 1.0.4 | ts-retry-circuit 2.1.1 | | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | | Retry recovery | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | Retry exhaustion | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | Zero retries | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | Retry + abort during backoff | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | ✅ | ✅ | | Bulkhead + queued abort | ✅ | ➖ | ➖ | ➖ | ➖ | ❌ | ➖ | ❌ | ❌ | ❌ | ➖ | | Hedge + streaming body | ✅ | ➖ | ➖ | ➖ | ➖ | ➖ | ➖ | ✅ | ➖ | ✅ | ➖ | | Circuit + hedge accounting | ✅ | ➖ | ➖ | ➖ | ➖ | ➖ | ➖ | ✅ | ➖ | ✅ | ➖ | | Retry over real HTTP | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | Timeout over real HTTP | ✅ | ✅ | ➖ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | Retry + total timeout | ✅ | ✅ | ➖ | ➖ | ❌ | ❌ | ➖ | ➖ | ❌ | ❌ | ➖ | | Retry + body replay | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | Retry-After + abort | ✅ | ✅ | ➖ | ➖ | ➖ | ❌ | ✅ | ❌ | ➖ | ➖ | ➖ | | Retry + throwing hooks | ✅ | ✅ | ✅ | ✅ | ❌ | ➖ | ✅ | ✅ | ➖ | ✅ | ✅ | | Hedge + all branches fail | ✅ | ➖ | ➖ | ➖ | ➖ | ➖ | ➖ | ✅ | ➖ | ✅ | ➖ | | Hedge + timeout | ✅ | ➖ | ➖ | ➖ | ➖ | ➖ | ➖ | ✅ | ➖ | ❌ | ➖ | | Dedupe + retry | ✅ | ➖ | ➖ | ➖ | ✅ | ➖ | ✅ | ❌ | ➖ | ➖ | ➖ | | Dedupe + caller cancellation | ✅ | ➖ | ➖ | ➖ | ✅ | ➖ | ✅ | ✅ | ➖ | ➖ | ➖ | | Bulkhead + retry | ✅ | ➖ | ➖ | ➖ | ➖ | ✅ | ➖ | ✅ | ✅ | ✅ | ➖ | | Circuit + retry accounting | ✅ | ➖ | ➖ | ➖ | ➖ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | Circuit + half-open concurrency | ❌ | ➖ | ➖ | ➖ | ➖ | ✅ | ❌ | ✅ | ✅ | ✅ | ❌ | | Circuit + local cancellation | ✅ | ➖ | ➖ | ➖ | ➖ | ❌ | ✅ | ❌ | ❌ | ✅ | ✅ |

Five scenarios pass in all eleven columns, which is 55 of the 147 implemented cells: Retry recovery: two 503 responses followed by a 200, with exactly three attempts. Retry exhaustion: three 503s, and the terminal result the library documents. Zero retries: one attempt, and the exact terminal response or error. Retry over real HTTP: three arrivals at a local server and a usable successful body. Retry + body replay: a POST body that reaches the server byte-for-byte on the retry, plus an explicit rejection when the body cannot be replayed.

Want to discuss this further?

Book a free strategy call with our team to see how these insights apply to your specific business goals.

Book a consultation