F Fch

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.

$ npm install github:westtrade/Fch
search.ts
// 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.

silent bug

Objects become [object Object]

fetch(url, { body: { name: "Cottage" } }) does not throw. It sends the literal string with text/plain.

silent hang

No timeout, ever

There is no timeout option. A stalled connection waits until the runtime gives up — which can be never.

silent pass

500 is a success

await fetch() resolves on a 404. The failure surfaces later, as undefined three layers away.

plain fetch
// 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
fch
// 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.

Full comparison
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.

app.ts
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`;