No dependencies · ~6 kB gzipped · MIT
The URL is the request.
Fch
extends the native URL class.
One object is both a fully mutable URL and the request that targets it —
with retries, timeouts, deduplication and typed body helpers on top.
No wrapper around fetch to keep in sync with a URL string.
No config object to rebuild. Change pathname, add a
searchParam, then await it.
// It IS a URL — the standard API works
const req = new Fch("https://api.example.com/v1/search");
req.pathname = "/v2/search"; // plain URL property
req.searchParams.set("q", "Tom & Jerry"); // encoded for you
req.searchParams.append("tag", "new");
// It is also the request
const [data, res] = await req.json<Results>();
req instanceof URL // true
The problem
fetch is a primitive. Every app rebuilds the same layer.
Timeouts, retries with backoff, HTTP error handling, JSON bodies, query encoding,
duplicate in-flight requests — none of it ships with fetch.
So it gets rewritten, differently, in every project. That is where the bugs live.
Objects become [object Object]
fetch(url, { body: { name: "Cottage" } }) does not throw.
It sends the literal string with text/plain.
No timeout, ever
There is no timeout option. A stalled connection
waits until the runtime gives up — which can be never.
500 is a success
await fetch() resolves on a 404.
The failure surfaces later, as undefined three layers away.
// timeout + retry + error handling, by hand
const c = new AbortController();
const id = setTimeout(() => c.abort(), 5000);
let last;
for (let i = 0; i < 4; i++) {
try {
const res = await fetch(url, { signal: c.signal });
if (!res.ok) throw new Error(res.status);
return res.json();
} catch (e) { last = e; }
await sleep(500 * 2 ** i); // no jitter
}
throw last; // and the timer still leaks
// the same job, declared once
const api = new Fch("https://api.example.com/orders", {
timeout: 5_000,
retries: 3,
retryDelay: 500,
retryOn: (r) => !!r && r.status >= 500,
});
const [orders] = await api.json<Order[]>();
// backoff: 500ms, 1s, 2s — each ±20% jitter
// abort() is never retried, timers are cleaned up
What you get
Everything around the request, nothing around that.
No second HTTP stack. Fch calls the global
fetch — it just stops you from writing the same
sixty lines before every call.
URL-first, not URL-adjacent
Extends URL: pathname, host, origin, searchParams, hash, toString(). Mutations change the request target.
Retries that are correct
Exponential backoff with jitter, capped. User aborts are never retried. One retryOn(response, error, attempt) predicate decides everything.
Per-attempt timeouts
Independent of the retry loop, so a slow attempt is retried instead of killing the request. Combines with your own AbortSignal.
Dedupe & idempotency
Concurrent identical GETs collapse to one request; each caller still gets a readable response. Writes need an explicit key, sent as Idempotency-Key.
Forms as first-class bodies
Pass the <form>, the submit Event, or FormData. The form's own method, the clicked submit button and the multipart boundary are handled.
Typed tuples, not guesswork
await req.json<User>() resolves to [data, response] — so the parsed body and the status are both there, without a second read.
Try it
Edit the URL. Watch the request change.
This runs the real library in your browser. Nothing is sent anywhere — the example
just shows what Fch would put on the wire.
mock fetch, no network
request that would be sent
response
Comparison
Smaller than axios, better than fetch.
Measured, not claimed. Sizes are gzipped minified bundles from bundlephobia for the latest published versions.
| Library | Gzipped | Deps | Instance is a URL |
Retry + timeout | Dedupe | Form body |
|---|---|---|---|---|---|---|
| fetch | 0 kB | 0 | ✗ | ✗ | ✗ | ✗ |
| @westtrade/fch | ~6 kB | 0 | ✓ | ✓ | ✓ | ✓ |
| axios | ~19 kB | 4 | ✗ | timeout only | ✗ | ✗ |
| ky | ~10.7 kB | 0 | ✗ | ✓ | ✗ | ✗ |
| ofetch | ~3.9 kB | 3 | ✗ | ✓ | ✗ | ✗ |
| wretch | ~1.8 kB | 0 | ✗ | addons | ✗ | ✗ |
Fch measured from its own build with esbuild; others from bundlephobia.
Where another library is the better choice, the
comparison page says so.
Quick start
One import. No setup.
Fch uses the global
fetch, so it works anywhere fetch does:
browsers, Node 18+, Deno, Bun, workers. There is nothing to configure.
import { Fch, fch } from "@westtrade/fch";
// a one-off request
const [user] = await new Fch("https://api.example.com/me", {
timeout: 3_000,
}).json<User>();
// a configured client
const api = fch.create({
baseUrl: "https://api.example.com",
retries: 2,
timeout: 5_000,
});
const [houses] = await api.get<House[]>("/houses");
api.post("/houses", { name: "Cottage" }); // JSON
// or put the URL in backticks
import lit from "@westtrade/fch/literal";
const [one] = await lit`https://api.example.com/houses/1`;