Cancellation
Cancel in-flight requests when the user leaves a page, closes a modal or types the next search keystroke. Cancel by key, group, URL pattern or scope.
Cancellation is off by default. Turn it on once:
const api = createClient({ baseUrl, cancel: true }); // GET requests become cancelableOnly GET is covered, because cancelling a read is always safe. A write may already have reached the server, so opt writes in deliberately:
createClient({ cancel: { methods: "all" } }); // every method
createClient({ cancel: { methods: ["GET", "DELETE"] } }); // your pick
await api.post("/draft", body, { cancelable: true }); // just this one
await api.get("/session", { cancelable: false }); // opt a read outCancel what the user left behind
Start three 4-second requests, then cancel one of them or the whole page. A canceled request resolves with canceled: true instead of throwing.
GET /usersnot sentGET /ordersnot sentGET /reportsnot sent
api.get("/users", { cancelKey: "users", cancelGroup: "page" });
api.cancel("users"); // one request
api.cancel("page"); // everything the page startedThree ways to cancel
By key or group. Name a request, then cancel it by name:
api.get("/search", { cancelKey: "search", params: { q } });
api.cancel("search");
api.get("/cart", { cancelGroup: "checkout" }); // a request can have several groups
api.get("/shipping", { cancelGroup: ["checkout", "page"] });
api.cancel("checkout"); // stops bothA group only tags a request. A tagged write still needs cancelable: true.
By URL. Cancel everything under a path, or everything in flight:
api.cancel("/api/products"); // /api/products, /api/products/12, …
api.cancel(); // all tracked requests, e.g. on route changeBy scope. Group what a modal or page starts, and cancel it on close:
const scope = api.cancelScope("product-modal");
await scope.get("/products/12");
scope.cancel(); // on closeA scope has get, post, put, patch, delete, cancel() and pending(). Its reads are cancelable even if the client never set cancel: creating the scope is the opt-in. Writes still need cancelable: true.
URL patterns
Patterns match whole path segments, so products never matches products-archive. Query strings are ignored.
| Pattern | Matches | Doesn't match |
|---|---|---|
/api/products | /api/products, /api/products/12, /api/products/12/reviews | /api/products-archive |
/api/products$ | /api/products only | /api/products/12 |
/users/:id or /users/* | /users/7, /users/7/posts | /users |
/users/:id$ | /users/7 | /users/7/posts |
/api/**/images | /api/images, /api/a/b/images | /other/images |
Every selector
api.cancel(); // everything in flight
api.cancel("/api/products"); // a string: a key, then a group, then a URL pattern
api.cancel(/\/products\/\d+$/); // a regex, tested on the URL and path
api.cancel({ url: "/users/:id", method: "GET" }); // all given fields must match: url, method, key, group
api.cancel((r) => Date.now() - r.startedAt > 10_000); // your own rule
api.cancel("/api/products", "left the page"); // with a reasoncancel() returns how many requests it stopped. With nothing in flight it returns 0, so it's always safe to call.
pending() takes the same selectors and lists what's in flight:
if (api.pending("/api/products").length) showSpinner();
api.pending().map((r) => `${r.method} ${r.path}`); // what patterns match against
// each entry: { id, method, url, path, key?, groups, startedAt }Stale search results
takeLatest cancels the previous request with the same key, so only the last keystroke's results arrive:
api.get("/search", { cancelKey: "search", takeLatest: true, params: { q } });Without a cancelKey, the identity is the method plus the path. Turn it on for every request with cancel: { takeLatest: true }; it also quietly removes the duplicate requests React StrictMode makes in development.
What a canceled request returns
It resolves, it doesn't throw:
const res = await api.get("/users", { cancelKey: "users" });
if (res.canceled) return; // the user moved on; not an error
// res: { statusCode: 0, status: false, canceled: true, cancelReason: "left the page", … }This is deliberate: if a cancellation threw, TanStack Query would treat it as a failure and retry the request you just canceled, and an async useEffect would log an unhandled rejection. onError never fires for a cancellation either.
If you'd rather it throw, set cancel: { throwOnCancel: true }, or throwOnCancel: true on one request. It's independent of throwError:
try {
await api.get("/report", { throwOnCancel: true });
} catch (e) {
if (e instanceof ApiError && e.canceled) return;
throw e;
}A timeout is not a cancellation: it's 408 and throws as usual. Your own AbortSignal behaves exactly like cancel(), so TanStack Query and SWR can cancel through theirs: api.get(url, { signal }).
login, logout and restoreSession are never canceled, even with methods: "all", so a route-change api.cancel() can't break signing in.
In worker mode, cancelling really stops the fetch inside the worker. It isn't just ignoring the answer.
In your framework
Cancel on unmount (React). Create the scope inside the effect, so a remount doesn't cancel the new request:
useEffect(() => {
const scope = api.cancelScope("user-list");
scope.get<User[]>("/users").then((res) => {
if (!res.canceled) setUsers(res.data ?? []);
});
return () => scope.cancel();
}, []);Cancel on navigation (Next.js App Router).
"use client";
import { usePathname } from "next/navigation";
export function CancelOnNavigate() {
const pathname = usePathname();
useEffect(() => () => { api.cancel(); }, [pathname]);
return null;
}Put <CancelOnNavigate /> in the root layout. Pass a pattern to cancel if some requests should survive navigation.
Vue. onUnmounted(() => scope.cancel()), or router.beforeEach(() => { api.cancel(); }) for every navigation.
Token Refresh
How @mrzr/api-client refreshes access tokens automatically, sends one refresh for many concurrent 401s, and how to map custom token response shapes.
Uploads & Downloads
Upload FormData, File, Blob and binary bodies with @mrzr/api-client, keep long uploads from failing on token expiry, and download files as a Blob.