fetchwise
v0.2.0
Published
Tiny, zero-dependency resilient fetch wrapper: retries, exponential backoff, timeouts, Retry-After — plus fetchJson (typed JSON + HttpError). Works in Node, Deno, Bun and the browser.
Maintainers
Readme
fetchwise
A tiny, zero-dependency resilient fetch — retries, exponential backoff, timeouts and Retry-After, in ~1 KB gzipped.
fetchwise wraps the native fetch you already use and makes it survive flaky
networks and overloaded servers — without changing the API you know.
import { fetchwise } from "fetchwise";
// Exactly like fetch, but it retries transient failures automatically.
const res = await fetchwise("https://api.example.com/users");
const users = await res.json();Why fetchwise?
- 🪶 Zero dependencies. ~1 KB gzipped. Nothing to audit, nothing to bloat your bundle.
- 🔁 Smart retries. Exponential backoff with full jitter, configurable per request.
- ⏱️ Per-attempt timeouts. Built on
AbortController— no hanging requests. - 🚦 Respects
Retry-After. Honors the server's own backoff hints on429/503. - 🌍 Runs everywhere. Node 18+, Deno, Bun, Cloudflare Workers and the browser — anywhere
fetchexists. - 🧩 Drop-in. Same signature as
fetch. Add resilience by passing one extraretryoption. - 🛡️ Type-safe. Written in TypeScript, ships full type declarations.
Install
npm install fetchwise
# or: pnpm add fetchwise / yarn add fetchwise / bun add fetchwiseNo build step needed — it ships ESM and CommonJS.
import { fetchwise } from "fetchwise"; // ESM / TypeScript
const { fetchwise } = require("fetchwise"); // CommonJSUsage
Drop-in replacement
Anywhere you call fetch, call fetchwise instead. By default it makes up to
4 attempts (1 + 3 retries) on network errors and on the status codes
408, 425, 429, 500, 502, 503, 504.
const res = await fetchwise("/api/data");JSON in one call
fetchJson retries like fetchwise, throws HttpError on a non-2xx (so you
never parse an error page by mistake), and returns typed JSON. Pass json to
serialize a body and set Content-Type automatically.
import { fetchJson, HttpError } from "fetchwise";
const user = await fetchJson<User>("/api/users/1");
const created = await fetchJson<User>("/api/users", { json: { name: "Ada" } });
try {
await fetchJson("/api/secret");
} catch (e) {
if (e instanceof HttpError) console.log(e.status, e.response.url);
}Tune the retry behavior
const res = await fetchwise("/api/data", {
method: "POST",
body: JSON.stringify({ hello: "world" }),
headers: { "content-type": "application/json" },
retry: {
retries: 5, // up to 6 attempts total
minDelay: 200, // first backoff (ms)
maxDelay: 10_000, // cap (ms)
timeout: 4_000, // abort & retry any attempt slower than 4s
onRetry: ({ attempt, delay, error, response }) => {
console.warn(`retry #${attempt} in ${delay}ms`, error ?? response?.status);
},
},
});Create a preconfigured client
Share defaults across your whole app — per-call options are merged on top.
import { create } from "fetchwise";
const api = create({ retries: 5, timeout: 5_000 });
await api("/users"); // uses the defaults
await api("/report", { retry: { retries: 0 } }); // override per callCancel with an AbortSignal
External aborts are respected immediately and are never retried.
const res = await fetchwise(url, { signal: AbortSignal.timeout(10_000) });Retry only what you want
await fetchwise(url, {
retry: {
// Custom status policy (array or predicate)
retryOnStatus: (status) => status >= 500,
// Custom error policy — e.g. don't retry DNS failures
retryOnError: (err) => !String(err).includes("ENOTFOUND"),
},
});API
fetchwise(input, init?) => Promise<Response>
Identical to fetch(input, init), plus an optional init.retry object.
Returns the final Response. Retryable statuses are returned as-is once retries
are exhausted (it does not throw on HTTP errors — same as fetch).
create(defaults?) => fetchwise
Returns a fetchwise function with the given RetryOptions baked in.
RetryOptions
| Option | Type | Default | Description |
| ------------------- | ------------------------------------------------- | ------------------------------------ | -------------------------------------------------------- |
| retries | number | 3 | Additional attempts after the first failure. |
| minDelay | number | 300 | Base backoff delay in ms. |
| maxDelay | number | 30000 | Maximum delay between attempts in ms. |
| factor | number | 2 | Exponential backoff multiplier. |
| jitter | boolean | true | Apply full random jitter to each delay. |
| timeout | number | 0 (off) | Per-attempt timeout in ms. |
| retryOnStatus | number[] \| (status, response) => boolean | [408,425,429,500,502,503,504] | Which statuses to retry. |
| retryOnError | (error, context) => boolean | retry all but external aborts | Whether a thrown error is retryable. |
| respectRetryAfter | boolean | true | Honor the Retry-After response header. |
| onRetry | (context) => void | — | Hook fired before each retry. |
Other exports
TimeoutError— thrown internally when atimeoutelapses (retried like any error).parseRetryAfter(value, now?)— parse aRetry-Afterheader into milliseconds.
How backoff works
Each retry waits min(maxDelay, minDelay × factor^(attempt-1)), then — with
jitter on (the default) — a random value between 0 and that ceiling. Full
jitter spreads out retries from many clients so they don't stampede a recovering
server. If the response carries a Retry-After header, that value wins.
Comparison
| | fetchwise | hand-rolled try/catch loop | heavier HTTP clients |
| ------------------------ | :---------: | :--------------------------: | :------------------: |
| Zero dependencies | ✅ | ✅ | ❌ |
| Native fetch signature | ✅ | ⚠️ | ❌ |
| Backoff + jitter | ✅ | ❌ | ✅ |
| Retry-After support | ✅ | ❌ | ⚠️ |
| Per-attempt timeout | ✅ | ❌ | ✅ |
| ~1 KB gzipped | ✅ | — | ❌ |
Contributing
Contributions are very welcome! Please read CONTRIBUTING.md
and our Code of Conduct. Good first issues are labeled
good first issue.
git clone https://github.com/didrod205/fetchwise.git
cd fetchwise
npm install
npm test💖 Sponsor
fetchwise is free and MIT-licensed, built and maintained in spare time. If it
saves you from writing yet another retry loop, please consider supporting it —
every bit helps keep the project healthy and the issues answered.
- ⭐ Star this repo — the simplest, free way to help others discover it.
- 🍋 Sponsor via Lemon Squeezy — one-time or recurring support.
Sponsoring? Open an issue and we'll add your name/logo here. Thank you! 🙏
License
MIT © fetchwise contributors
