npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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.

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 celero
import { 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

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");   // untouched

The 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 a transformResponse — 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, attempt

response.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=true
  • null and undefined values are omitted, not sent as "null".
  • Date values are serialized as ISO strings.
  • Client-level params merge 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=ada

Nested 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]=ada

Arrays 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,b

Path 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%20b

A 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: web

Setting 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 Modified is outside the default 2xx range. Conditional requests need to opt it in via validateStatus.


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 limit

The 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 thing

attempts 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. POST and PATCH are 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 request

Every 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 caller

Conditional 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 reused

On 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-Length is 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 retry the way a hand-rolled stream body would.
  • FormData reports no total. Its encoded size, with the multipart boundary, is not knowable without buffering the whole payload — so you get loaded and rate but no percentage. Upload a Blob/File directly 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 string

Validation 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 all

Ids 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 asserted

Unwrapping 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/:

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 ReadableStream is 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 in celero/react. There are no Vue or Svelte bindings; the seam for them is queryClient.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 CacheStore for that.
  • Mutations do not invalidate the cache automatically. Call api.cache.invalidate() after a write.
  • Streams are never cached, deduplicated or retried. A ReadableStream has one consumer and cannot be replayed.
  • <T> is an assertion, not validation. Use schema when 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