celero
v0.2.5
Published
Swift, production-grade HTTP for JavaScript. TypeScript-first fetch client with retries, circuit breaker, caching, deduplication, SSE/NDJSON streaming, schema validation and typed errors. Zero dependencies.
Maintainers
Readme
Celero
Production networking, built on Fetch.
Celero — from Latin celer, "swift."
A TypeScript-first HTTP client that adds the things you always end up writing by
hand around fetch() — base URLs, JSON encoding, query strings, timeouts,
retries with backoff, interceptors and typed errors — without pulling in a
single runtime dependency.
npm install celeroimport { createClient } from "celero";
const api = createClient({
baseURL: "https://api.example.com",
timeout: 10_000,
retry: { attempts: 3 },
});
const { data } = await api.get<User>("/users/123");Table of contents
- Why Celero
- Requirements
- Creating a client
- Making requests
- The response object
- Request bodies
- Query parameters
- Headers
- Error handling
- Timeouts and cancellation
- Retries
- Circuit breaker
- Caching
- Deduplication
- Streaming: SSE and NDJSON
- Upload and download progress
- Schema validation
- Interceptors
- Extending clients
- Response types
- Transforms
- Testing
- Runtime support
- Query layer
- React
- API reference
- Design notes and limitations
Why Celero
fetch() is the right primitive and the wrong ergonomic. Every codebase that
uses it directly grows the same wrapper: a base URL, JSON.stringify on the way
out, res.json() on the way back, an if (!res.ok) throw that loses the error
body, an AbortController for timeouts, and a retry loop someone wrote at 2am.
Celero is that wrapper, written once and tested.
| | Raw fetch | Celero |
| --- | --- | --- |
| Non-2xx | Resolves. You must check res.ok | Throws HttpError with the parsed error body |
| JSON | JSON.stringify / res.json() by hand | Automatic in both directions |
| Base URL | Manual string concatenation | baseURL |
| Query strings | Build URLSearchParams yourself | params, with array formats |
| Timeouts | Wire up an AbortController | timeout: 5000 |
| Retries | Write the loop | retry: { attempts: 3 }, exponential backoff + jitter |
| Errors | TypeError: fetch failed for everything | Distinct typed errors with guards |
| Auth refresh | Scattered call sites | One response interceptor |
| Streaming | Hand-rolled SSE parser | api.sse() / api.ndjson() async iterables |
| Validation | as User and hope | schema with Zod, Valibot or ArkType |
| Outage behaviour | Retries pile on | Circuit breaker fails fast |
| Caching | None | TTL, stale-while-revalidate, ETag |
| Progress | No API at all | onUploadProgress / onDownloadProgress |
What it deliberately is not: a request/response mocking framework, a cache, or a replacement for your data-fetching library. It is the transport layer.
Requirements
A runtime with global fetch, AbortController and Request/Response:
- Node.js 18+
- Bun, Deno
- All modern browsers
- Cloudflare Workers and other edge runtimes
- React Native (0.71+)
On older runtimes, or when you want to route through a custom agent, pass your own implementation — see Testing.
Creating a client
createClient takes the defaults every request from that client will inherit.
import { createClient } from "celero";
const api = createClient({
baseURL: "https://api.example.com/v1",
headers: {
accept: "application/json",
"x-client": "web",
},
timeout: 10_000,
retry: { attempts: 3 },
});Every option can be overridden per call:
await api.get("/slow-report", { timeout: 60_000 });For a one-off request you do not want to build a client for, use the default export-style singleton:
import { celero } from "celero";
const { data } = await celero.get<Health>("https://api.example.com/health");Prefer createClient for anything beyond a couple of calls — shared defaults
and interceptors are the whole point.
Making requests
Methods without a body take (url, options):
await api.get<User>("/users/1");
await api.delete("/users/1");
await api.head("/users/1");
await api.options("/users");Methods with a body take (url, body, options):
await api.post<User>("/users", { name: "Ada" });
await api.put<User>("/users/1", { name: "Ada Lovelace" });
await api.patch<User>("/users/1", { name: "Ada L." });request is the general form, and the only way to send a non-standard method:
await api.request<Report>("/reports", {
method: "REPORT",
body: { range: "30d" },
});Relative paths resolve against baseURL. An absolute URL ignores it entirely,
so one client can still reach another origin:
await api.get("/users"); // https://api.example.com/v1/users
await api.get("https://status.example.com/up"); // untouchedThe type parameter flows through to response.data:
interface User {
id: number;
name: string;
}
const response = await api.get<User>("/users/1");
response.data.name; // string
<T>is an assertion about what the server returns, not a validation of it. If you need a guarantee, parse in atransformResponse— see Transforms.
The response object
Every successful call resolves to a CeleroResponse<T>:
const response = await api.get<User>("/users/1");
response.data; // T — the parsed body
response.status; // 200
response.statusText; // "OK"
response.headers; // Headers
response.url; // final URL, after redirects
response.ok; // whether the status passed validateStatus
response.raw; // the underlying Response (body already consumed)
response.request; // the resolved request: method, url, headers, attemptresponse.request.attempt tells you how many tries it took, which is useful in
logs when retries are on.
Request bodies
Plain objects and arrays are JSON-encoded and get a
content-type: application/json header automatically:
await api.post("/users", { name: "Ada" });
// body: '{"name":"Ada"}'Anything fetch already understands is passed through untouched, so the runtime
can set the right content type itself — including the multipart boundary:
const form = new FormData();
form.set("avatar", file);
await api.post("/avatar", form); // no content-type forced
await api.post("/form", new URLSearchParams({ a: "1" }));
await api.post("/raw", new Uint8Array([1, 2, 3]));
await api.post("/text", "already serialized");To send JSON with a different content type, set it explicitly:
await api.post("/events", { type: "click" }, {
headers: { "content-type": "application/vnd.api+json" },
});GET and HEAD never send a body, even if you pass one.
Query parameters
await api.get("/users", { params: { page: 2, active: true } });
// /users?page=2&active=truenullandundefinedvalues are omitted, not sent as"null".Datevalues are serialized as ISO strings.- Client-level
paramsmerge with per-request ones; a per-request key replaces the inherited value rather than appending to it. - A query string already in the path is preserved.
const api = createClient({
baseURL: "https://api.example.com",
params: { api_key: "abc" },
});
await api.get("/search?sort=asc", { params: { q: "ada", page: null } });
// /search?sort=asc&api_key=abc&q=adaNested objects use bracket notation, which is what JSON:API and Rails-style backends expect:
await api.get("/issues", { params: { filter: { status: "open", owner: "ada" } } });
// /issues?filter[status]=open&filter[owner]=adaArrays support four encodings via arrayFormat (default "repeat"):
await api.get("/posts", { params: { tag: ["a", "b"] }, arrayFormat: "repeat" });
// tag=a&tag=b
// "brackets" -> tag[]=a&tag[]=b
// "indices" -> tag[0]=a&tag[1]=b
// "comma" -> tag=a,bPath parameters
:name placeholders keep ids out of template literals, and values are
percent-encoded so they cannot break out of their segment:
await api.get("/users/:id/posts/:postId", { pathParams: { id: 7, postId: "a b" } });
// /users/7/posts/a%20bA missing placeholder value throws ConfigError rather than sending :id
literally.
Headers
Headers merge across layers, and keys are case-insensitive:
const api = createClient({
headers: { authorization: "Bearer root", "x-app": "web" },
});
await api.get("/x", { headers: { Authorization: "Bearer scoped" } });
// authorization: Bearer scoped
// x-app: webSetting a header to null removes an inherited one — how a single request
opts out of a client-wide Authorization:
await api.get("/public", { headers: { authorization: null } });Error handling
Celero throws on failure instead of handing you a response to inspect. A non-2xx status is an error, and the parsed error body is on it:
import { isHttpError } from "celero";
try {
await api.get<User>("/users/999");
} catch (error) {
if (isHttpError<{ message: string }>(error)) {
error.status; // 404
error.response.data.message // "User not found" — the server's error body
error.response.headers; // Headers
error.request.url; // the URL that failed
}
}The error types
| Class | Code | Thrown when |
| --- | --- | --- |
| HttpError | ERR_HTTP_STATUS | A response arrived but its status failed validateStatus |
| TimeoutError | ERR_TIMEOUT | The request outlived its timeout |
| NetworkError | ERR_NETWORK | The request never reached the server (DNS, TLS, offline, CORS) |
| CanceledError | ERR_CANCELED | Your AbortSignal fired |
| ParseError | ERR_PARSE | The body could not be decoded as the requested type |
| ConfigError | ERR_CONFIG | The request was unusable before dispatch |
| CircuitOpenError | ERR_CIRCUIT_OPEN | The circuit breaker is open; no request was sent |
All seven extend CeleroError, so one catch distinguishes "the request
failed" from "my code threw":
import { isCeleroError, ErrorCode } from "celero";
try {
await api.get("/users");
} catch (error) {
if (!isCeleroError(error)) throw error; // a real bug — let it surface
switch (error.code) {
case ErrorCode.HttpStatus: return showServerMessage(error);
case ErrorCode.Timeout:
case ErrorCode.Network: return showRetryPrompt();
case ErrorCode.Canceled: return; // the user navigated away
default: throw error;
}
}Guards are exported for each: isHttpError, isTimeoutError, isNetworkError,
isCanceledError, isParseError, isCircuitOpenError, isCeleroError.
Every error has a toJSON() producing a flat, log-friendly object:
logger.error(error.toJSON());
// { name: "HttpError", code: "ERR_HTTP_STATUS", status: 503,
// method: "GET", url: "https://api.example.com/v1/users", attempt: 3 }Choosing what counts as success
// Treat 404 as a normal outcome rather than an exception.
const response = await api.get("/users/1", {
validateStatus: (status) => status === 404 || (status >= 200 && status < 300),
});
if (response.status === 404) return null;validateStatus: null disables status checking entirely — every response
resolves, and you inspect response.status yourself.
Note:
304 Not Modifiedis outside the default 2xx range. Conditional requests need to opt it in viavalidateStatus.
Timeouts and cancellation
timeout is milliseconds, and 0 (the default) means no timeout:
const api = createClient({ baseURL: "...", timeout: 10_000 });
await api.get("/report", { timeout: 60_000 }); // this one may take longer
await api.get("/stream", { timeout: 0 }); // no limitThe timeout covers connection, response and body decoding. When it elapses the
underlying fetch is aborted and a TimeoutError is thrown.
Your own signal composes with it — whichever fires first wins, and each produces its own error type:
const controller = new AbortController();
const pending = api.get("/search", {
params: { q },
signal: controller.signal,
timeout: 5_000,
});
controller.abort(); // -> CanceledError (not TimeoutError)Cancellation is honoured between retries too: aborting during a backoff sleep stops the retry loop immediately rather than waiting it out.
Retries
Retries are off by default. Turn them on with a count or a policy:
const api = createClient({ retry: 3 }); // shorthand
const api = createClient({ retry: { attempts: 3 } }); // same thingattempts is the number of retries after the first try, so attempts: 3
means up to 4 requests.
What is retried by default
- Methods:
GET,HEAD,OPTIONS,PUT,DELETE— the idempotent ones.POSTandPATCHare excluded, because replaying them can create a second order. Opt in explicitly if your endpoint is safe. - Statuses:
408,425,429,500,502,503,504. - Transport failures: any
NetworkError.
CanceledError is never retried.
Backoff
Exponential with full jitter, capped:
retry: {
attempts: 4,
delay: 300, // base, ms
backoffFactor: 2, // 300 -> 600 -> 1200 -> 2400
maxDelay: 30_000, // cap for any single wait
jitter: true, // randomize within the window (default, keep it on)
}Jitter is on by default because clients retrying on identical schedules is what turns a brief blip into a thundering herd.
A Retry-After header — in seconds or as an HTTP date — overrides the computed
backoff. Set respectRetryAfter: false to ignore it.
Full control
const api = createClient({
retry: {
attempts: 5,
methods: ["GET", "POST"], // this API's POSTs are idempotent
statusCodes: [429, 503],
shouldRetry: ({ response, error, attempt }) => {
if (response?.status === 429) return true; // always back off on rate limits
if (response?.status === 503) return attempt < 3;
return Boolean(error); // transport failures
},
onRetry: ({ attempt, delay, request, response }) => {
logger.warn("retrying", {
url: request.url,
attempt,
delay,
status: response?.status,
});
},
},
});shouldRetry replaces the built-in decision entirely. onRetry runs before
each backoff — use it for logging and metrics.
Circuit breaker
Retries alone make an outage worse: every client multiplies its load against a service that is already failing. The breaker is what stops that. After enough consecutive failures it fails fast for a cooldown, then lets a single probe through before closing again.
Off by default. true enables it with sensible settings:
const api = createClient({
baseURL: "https://api.example.com",
retry: { attempts: 3 },
breaker: true,
});breaker: {
failureThreshold: 5, // consecutive failures before opening
resetTimeout: 30_000, // how long to stay open
successThreshold: 1, // probes needed to close again
onStateChange: (state, key) => logger.warn("circuit", { state, key }),
}While the circuit is open, requests throw CircuitOpenError without touching
the network:
import { isCircuitOpenError } from "celero";
try {
await api.get("/users");
} catch (error) {
if (isCircuitOpenError(error)) {
// error.circuitKey -> "https://api.example.com"
// error.retryAfter -> ms until the next probe
return cachedFallback();
}
throw error;
}Circuits are keyed by origin, so one failing host never trips requests to
another. A 4xx does not count as a failure — the caller sent something wrong,
the server is fine. Only 5xx, timeouts and transport errors do.
api.breaker.stateOf("https://api.example.com"); // "closed" | "open" | "half-open"
api.breaker.reset();Circuit state is shared across extend(), because a failing origin is a global
fact rather than a per-client one.
Caching
An in-memory response cache with TTL freshness, stale-while-revalidate, and
ETag / Last-Modified revalidation.
This is not a replacement for TanStack Query or SWR. Those are data layers: React hooks, refetch on window focus, cross-component invalidation, subscriptions. This is the caching engine underneath — useful on its own, and happy to sit below one of those libraries.
Off by default. A number is shorthand for { ttl }:
const api = createClient({ baseURL: "...", responseCache: 60_000 });
await api.get("/config"); // network
await api.get("/config"); // cache hit, no requestEvery response reports where it came from:
const { data, cached } = await api.get("/config");Stale-while-revalidate
Serve instantly from cache, refresh in the background:
responseCache: {
ttl: 30_000, // fresh for 30s
staleWhileRevalidate: 300_000, // then serve stale for 5 more minutes
} // while refreshing behind the callerConditional revalidation
When an entry expires but carries a validator, the next request is conditional.
An unchanged resource costs a 304 instead of a whole body:
// Request: If-None-Match: W/"v1"
// Response: 304 Not Modified -> the stored body is reusedOn by default; disable with revalidate: false.
HTTP headers win by default
Cache-Control from the response overrides your ttl, and no-store /
private prevent storage entirely. Set respectCacheControl: false to ignore
the server and use your own numbers.
Keys and isolation
Entries are keyed by method, URL and varying headers — including
Authorization by default, so two users sharing a client never share cache
entries. The cost is a cache miss after a token refresh, which is the right
trade.
responseCache: { ttl: 60_000, vary: ["accept", "authorization", "x-tenant"] }Invalidation
await api.cache.invalidate("/users/1"); // one URL
await api.cache.invalidate({ url: "/users/1", method: "GET" });
await api.cache.invalidate((key) => key.includes("/users/")); // predicate
await api.cache.clear();Mutations do not invalidate automatically — call invalidate yourself after a
write, so the rules stay explicit:
await api.put("/users/1", patch);
await api.cache.invalidate("/users/1");Custom stores
The default is an in-memory LRU (500 entries). Any object with
get/set/delete/clear works, sync or async, so localStorage and
IndexedDB are fine:
responseCache: { ttl: 60_000, store: new MemoryCacheStore(2_000) }Only GET and HEAD are cached, and only successful responses.
Deduplication
Identical requests that overlap in time become one network call:
const api = createClient({ baseURL: "...", dedup: true });
// One request, three callers.
const [a, b, c] = await Promise.all([
api.get("/users/1"),
api.get("/users/1"),
api.get("/users/1"),
]);Callers are reference-counted. One of them aborting rejects only that caller; the shared request is cancelled only once every caller has abandoned it. This is what stops a single unmounting component from cancelling a request its siblings are still waiting on.
Like the cache, dedup covers GET and HEAD only and keys on
Authorization, so two users never share a response. In-flight requests are
shared across extend().
Streaming: SSE and NDJSON
Server-Sent Events, as an async iterable:
const { data: events } = await api.sse("/chat/completions", {
method: "POST",
body: { model: "...", stream: true },
});
for await (const event of events) {
if (event.data === "[DONE]") break;
const { delta } = JSON.parse(event.data);
process.stdout.write(delta);
}Each event is { event, data, id?, retry? }. The parser follows the WHATWG
rules: : lines are comments, multiple data: lines join with newlines, and
events split across network chunks — or multi-byte characters split mid-rune —
are reassembled correctly.
Newline-delimited JSON:
const { data: rows } = await api.ndjson<LogLine>("/logs/export");
for await (const row of rows) {
console.log(row.level, row.message);
}Both are also available as response types on any method:
await api.request("/events", { responseType: "sse" });
await api.request("/export", { responseType: "ndjson" });Streams are never cached or deduplicated — a ReadableStream has exactly
one consumer, so sharing one between callers would starve all but the first.
The timeout covers connecting and reading headers, not the lifetime of the
stream.
Upload and download progress
await api.post("/uploads", file, {
onUploadProgress: ({ loaded, total, ratio, rate }) => {
bar.value = ratio ?? 0;
label.textContent = `${fmt(loaded)} / ${fmt(total)} at ${fmt(rate)}/s`;
},
});
await api.get("/export.zip", {
responseType: "blob",
onDownloadProgress: ({ ratio }) => (bar.value = ratio ?? 0),
});Each callback receives:
interface Progress {
loaded: number; // bytes transferred so far
total?: number; // only when the size is knowable up front
ratio?: number; // 0–1, only when total is known
rate: number; // bytes/second; 0 until measurable
done: boolean; // true on the final report
}How upload progress works, and where it works
The Fetch standard has no upload progress event. Celero gets one by sending the
body as a ReadableStream and counting bytes as the transport pulls them —
which is a real measure of what has been handed to the connection.
That has consequences worth knowing:
| Runtime | Upload progress | Notes | | --- | --- | --- | | Node 18+, Deno, Bun | ✅ | Full support | | Chrome / Edge 105+ | ⚠️ | HTTP/2 or HTTP/3 only — request streaming is rejected on HTTP/1.1 | | Safari, Firefox | ❌ | No request streaming; the callback never fires | | Cloudflare Workers | ✅ | |
Download progress works everywhere, because response bodies have always been streams.
If you need upload progress in every browser, use XMLHttpRequest for that one
request — fetch is injectable, so it stays a local decision:
const uploader = createClient({ fetch: xhrAdapter });Details
Content-Lengthis preserved. Streaming a body normally drops it and falls back to chunked encoding, which breaks servers that require a length (S3 presigned PUTs, for one). Celero sets it back whenever the size is known. Browsers forbid setting this header and will use chunked regardless.- Retries still work. The body is rebuilt for each attempt, so enabling
progress does not silently disable
retrythe way a hand-rolled stream body would. FormDatareports nototal. Its encoded size, with the multipart boundary, is not knowable without buffering the whole payload — so you getloadedandratebut no percentage. Upload aBlob/Filedirectly when you need a percentage.- Progress callbacks are not debounced. Throttle them yourself before touching the DOM on every chunk.
Schema validation
<T> is an assertion. schema is a guarantee. Pass any
Standard Schema — Zod, Valibot or ArkType — and
the response is validated and the type inferred:
import { z } from "zod";
const User = z.object({ id: z.number(), name: z.string(), email: z.email() });
const { data } = await api.get("/users/1", { schema: User });
// ^? { id: number; name: string; email: string }No type argument needed — it comes from the schema. Transforms and defaults
apply as usual, and a mismatch throws ParseError naming the offending field:
ParseError: Response from https://api.example.com/users/1 failed schema
validation — id: Invalid input: expected number, received stringValidation runs after transformResponse, so you can unwrap an envelope and
then validate what is inside.
For NDJSON, each item is validated as it arrives, so a bad row fails at the row rather than at the end:
const { data } = await api.ndjson("/logs", { schema: LogLine });Celero has no dependency on any schema library — the Standard Schema interface is declared structurally, so the package stays zero-dependency.
Interceptors
Request interceptors
Run in registration order, before dispatch. Mutate and return the request:
api.interceptors.request.use((request) => {
request.headers["authorization"] = `Bearer ${getToken()}`;
request.headers["x-request-id"] = crypto.randomUUID();
return request;
});They can be async, which is how you await a token:
api.interceptors.request.use(async (request) => {
request.headers["authorization"] = `Bearer ${await getFreshToken()}`;
return request;
});Throwing from one rejects the request before anything is sent.
Request interceptors run once per request, not once per retry attempt.
Response interceptors
Run in registration order on the way back. The second argument sees errors:
api.interceptors.response.use(
(response) => {
metrics.timing("http", { status: response.status, url: response.url });
return response;
},
(error) => {
metrics.increment("http.error");
throw error; // rethrow, or return a response to recover
},
);Returning a response from the rejection handler recovers the request:
api.interceptors.response.use(undefined, (error) => {
if (isHttpError(error) && error.status === 404) {
return { ...error.response, data: null };
}
throw error;
});Token refresh
The canonical pattern, with a shared in-flight promise so a burst of 401s triggers exactly one refresh:
let refreshing: Promise<string> | null = null;
api.interceptors.response.use(undefined, async (error) => {
if (!isHttpError(error) || error.status !== 401) throw error;
if (error.request?.meta.retried) throw error; // only try once
refreshing ??= refreshToken().finally(() => {
refreshing = null;
});
const token = await refreshing;
return api.request(error.request!.url, {
...error.request!.options,
headers: { ...error.request!.headers, authorization: `Bearer ${token}` },
meta: { retried: true },
});
});meta is arbitrary data carried through the pipeline and never sent over the
wire — the right place for flags like this.
Removing interceptors
const id = api.interceptors.request.use(addAuth);
api.interceptors.request.eject(id);
api.interceptors.request.clear(); // remove allIds stay stable after an ejection.
Extending clients
extend creates a child that inherits defaults and a copy of the current
interceptors, with overrides layered on top:
const base = createClient({
baseURL: "https://api.example.com",
timeout: 10_000,
});
base.interceptors.request.use(addAuth);
const billing = base.extend({
baseURL: "https://api.example.com/billing",
retry: { attempts: 5 },
headers: { "x-scope": "billing" },
});Headers, meta, fetchOptions and object-form retry policies merge field by
field. Everything else is replaced.
Interceptors are copied at the moment extend() is called — a child never
mutates its parent, and interceptors added to the parent afterwards do not reach
existing children. Register shared interceptors on the base client first.
Response types
By default (responseType: "auto") the body is decoded from its Content-Type:
| Content-Type | Parsed as |
| --- | --- |
| application/json, *+json | json |
| text/*, application/xml, application/x-www-form-urlencoded | text |
| multipart/form-data | FormData |
| missing | text |
| anything else | Blob (or ArrayBuffer where Blob is unavailable) |
Bodiless responses — 204, 205, 304, or content-length: 0 — give
data === undefined rather than an empty string.
Force a reader when the server lies about its content type:
await api.get("/data", { responseType: "json" });
await api.get("/file", { responseType: "blob" });
await api.get("/file", { responseType: "arrayBuffer" });
await api.get("/page", { responseType: "text" });"stream" hands back response.data as the raw ReadableStream without
consuming it:
const { data: stream } = await api.get<ReadableStream>("/download", {
responseType: "stream",
timeout: 0,
});
for await (const chunk of stream) {
// ...
}Transforms
transformRequest runs after body encoding, before dispatch:
await api.post("/events", { type: "click" }, {
transformRequest: (body) => compress(body as string),
});transformResponse runs on the parsed body, before it becomes response.data.
This is where runtime validation belongs:
const response = await api.get("/users/1", {
transformResponse: (data) => UserSchema.parse(data), // zod, valibot, ...
});
response.data; // validated, not just assertedUnwrapping an envelope is the other common use:
const api = createClient({
baseURL: "https://api.example.com",
transformResponse: (data) => (data as { data: unknown }).data,
});Testing
Inject a fetch implementation instead of patching globals:
import { createClient } from "celero";
const api = createClient({
baseURL: "https://api.example.com",
fetch: async (url, init) => {
expect(url).toBe("https://api.example.com/users/1");
expect(init.method).toBe("GET");
return new Response(JSON.stringify({ id: 1, name: "Ada" }), {
headers: { "content-type": "application/json" },
});
},
});
const { data } = await api.get<User>("/users/1");The same hook routes requests through a proxy agent, an instrumented fetch, or
a runtime-specific implementation:
import { fetch as undiciFetch, ProxyAgent } from "undici";
const dispatcher = new ProxyAgent("http://proxy.internal:8080");
const api = createClient({
fetch: (url, init) => undiciFetch(url, { ...init, dispatcher }) as Promise<Response>,
});When fetch is not supplied, the global is resolved per request, so a test
double installed on globalThis after the client is created still takes effect.
Set retry.delay: 0 and jitter: false in tests to keep retry cases fast and
deterministic.
Runtime support
Zero runtime dependencies, no Node built-ins, published as ESM and CJS with
sideEffects: false so bundlers can tree-shake unused exports.
Cloudflare Workers and similar runtimes accept non-standard fetch options via
fetchOptions:
await api.get("/cached", {
fetchOptions: { cf: { cacheTtl: 300, cacheEverything: true } },
});Query layer
A framework-agnostic data layer ships as a separate entry point, so it costs nothing unless you import it:
import { createClient } from "celero";
import { queryClient, queryOptions } from "celero/query";
const api = createClient({ baseURL: "https://api.example.com" });
const userQuery = (id: string) =>
queryOptions({
queryKey: ["users", id],
queryFn: ({ signal }) => api.get(`/users/${id}`, { signal }).then((r) => r.data),
staleTime: 30_000,
});
const user = await queryClient.fetchQuery(userQuery("1"));A shared queryClient is ready to use — no new QueryClient() in every file.
Configure it once with configureQueryClient(), or construct your own when you
need isolation.
It covers request caching, deduplication, stale-while-revalidate, background refetch, invalidation, optimistic updates, mutations, pagination, infinite queries, prefetching, offline persistence, cross-tab sync, cancellation and garbage collection.
Two caches, two jobs. Celero's transport cache answers "have I fetched
this URL recently" and understands ETag and Cache-Control. The query layer
answers "what does the UI know, and who is watching it". They compose, and
neither requires the other.
// Optimistic update with exact rollback
const toggle = qc.mutation({
mutationFn: (done) => api.put(`/todos/${id}`, { done }).then((r) => r.data),
onMutate: (done) => {
qc.cancelQueries(["todos", id]);
const previous = qc.getQueryData(["todos", id]);
qc.setQueryData(["todos", id], { id, done });
return { previous };
},
onError: (_e, _v, ctx) => qc.setQueryData(["todos", id], ctx.previous),
onSettled: () => qc.invalidateQueries({ queryKey: ["todos", id] }),
});Framework bindings are not included — qc.observe() is the seam a React hook
sits on. Full guide: docs/query.md.
React
import { useQuery, useMutation } from "celero/react";
function User({ id }) {
const { data, isPending, isError, error } = useQuery(userQuery(id));
if (isPending) return <Spinner />;
if (isError) return <Error message={error.message} />;
return <h1>{data.name}</h1>;
}useQuery, useMutation, useInfiniteQuery, useQueryClient,
useIsFetching, useIsMutating and QueryClientProvider — all built on
useSyncExternalStore, so they are correct under concurrent rendering and do
not double-fetch in StrictMode. No provider is required; hooks fall back to the
shared client.
React is an optional peer dependency, and celero/react is a separate
entry point, so it costs nothing unless imported. Full guide:
docs/react.md.
API reference
Detailed references live in docs/:
docs/api.md— every option, method and typedocs/recipes.md— auth, pagination, uploads, concurrency, edgedocs/query.md— the query layer: caching, mutations, pagination, persistencedocs/react.md— React hooksdocs/SPEC.md— the original product specification
Exports at a glance
import {
// client
createClient,
celero,
// errors
CeleroError, HttpError, TimeoutError,
NetworkError, CanceledError, ParseError, ConfigError, CircuitOpenError,
ErrorCode,
isCeleroError, isHttpError, isTimeoutError,
isNetworkError, isCanceledError, isParseError, isCircuitOpenError,
// caching, resilience, streaming
MemoryCacheStore, CircuitBreaker, Deduper,
parseSSE, parseNDJSON,
requestKey, DEFAULT_VARY_HEADERS, DEFAULT_CACHE_METHODS,
parseMaxAge, parseStaleWhileRevalidate,
defaultBreakerKey, defaultIsFailure,
// building blocks, for custom pipelines
InterceptorRegistry,
buildURL, joinURL, isAbsoluteURL, toSearchParams,
mergeHeaders, normalizeHeaders,
mergeOptions, defaultValidateStatus,
computeBackoff, parseRetryAfter,
DEFAULT_RETRY_METHODS, DEFAULT_RETRY_STATUS_CODES,
} from "celero";
import type {
ClientOptions, RequestOptions, CeleroClient, CeleroResponse,
ResolvedRequest, RetryOptions, RetryContext,
ResponseType, ArrayFormat, HttpMethod, QueryParams, QueryObject,
FetchLike, Interceptor, InterceptorManager,
CacheOptions, CacheEntry, CacheStore, CacheController,
BreakerOptions, CircuitState, CircuitBreakerController,
DedupOptions, ServerSentEvent, StreamResponse,
StandardSchemaV1, InferSchema, WithSchema,
} from "celero";Design notes and limitations
Things worth knowing before you adopt it:
- Request interceptors run once per request, not per retry attempt. A token that expires mid-retry will not be refreshed by a request interceptor; handle that in a response interceptor.
- Stream request bodies are never retried. A
ReadableStreamis consumed by the first attempt and cannot be replayed, so retry is skipped for it. - Retry order vs. interceptors. Retries happen inside dispatch, below the interceptor chain. Response interceptors see only the final outcome.
- Upload progress needs request streaming. It works on Node, Deno, Bun, Workers and Chromium over HTTP/2, but not on Safari or Firefox. Download progress works everywhere. See Upload and download progress.
- Core caching is transport-level. For UI-level concerns — observers,
window-focus refetch, mutations, optimistic updates — use
celero/query, with React hooks incelero/react. There are no Vue or Svelte bindings; the seam for them isqueryClient.watchQuery(). - No suspense or devtools UI.
- The cache is per-process. The default store is in-memory and is not
shared between server instances. Supply your own
CacheStorefor that. - Mutations do not invalidate the cache automatically. Call
api.cache.invalidate()after a write. - Streams are never cached, deduplicated or retried. A
ReadableStreamhas one consumer and cannot be replayed. <T>is an assertion, not validation. Useschemawhen the payload is untrusted.
Development
npm install
npm run typecheck # tsc --noEmit
npm test # vitest
npm run test:coverage
npm run build # tsdown -> dist/ (ESM + CJS + .d.ts)License
MIT
