Troubleshooting
Common issues, debugging tips, and solutions
Error messages, decoded
addToUrl contains a falsy segment at index 0: [null]
A segment was null, undefined or "" — almost always an unresolved ID.
api.get("/users", { addToUrl: [userId] }); // userId is undefinedGuard, or use a template:
if (!userId) return;
api.get("/users/{id}", { addTemplateToUrl: { id: userId } });0 is explicitly allowed — it's a valid ID.
A ReadableStream body cannot be sent through a Web Worker.
Streams aren't structured-cloneable. Three fixes, best first:
await api.post("/upload", file); // send a Blob/File instead
const streamApi = createClient({ baseUrl, worker: false }); // a dedicated client
const api = createClient({ baseUrl, worker: false }); // disable the workerSee [[Uploads and Binary Bodies]].
Access token expired during a streamed upload and the stream cannot be replayed.
A 401 hit mid stream-upload. The token has been refreshed, so retrying with a fresh stream succeeds. Prevent it with uploadSkewMs:
await api.post("/upload", makeStream(), { uploadSkewMs: 600_000, timeout: 0 });Request timed out (statusCode: 408)
The per-attempt budget elapsed (default 30 s).
await api.get("/report", { timeout: 120_000 });
await api.post("/upload", form, { timeout: 0 }); // no limitRemember the budget is per attempt — a 401 + retry can take twice as long in wall-clock terms.
Request aborted / Request canceled: … (statusCode: 0)
The request was canceled: your AbortSignal fired, cancel() matched it, takeLatest superseded it, or destroy() was called. Usually intentional. Filter it out with the flag:
catch (e) {
if (e instanceof ApiError && e.canceled) return;
throw e;
}e.cancelReason tells you which cancel() call did it, when a reason was given. onError is never fired for these.
cancel() returns 0 and nothing stops
Cancellation is opt-in, and only tracked requests can be canceled. Check, in order:
- The client never enabled it.
createClient({ cancel: true }), or use acancelScope, which enables itself. - It's a write. Only
GETis tracked by default. Passcancelable: true, orcancel: { methods: "all" }. - The request isn't in flight yet.
awaiting a slowawaitbefore firing? Checkapi.pending()to see what's actually tracked. - The pattern doesn't match. Patterns are segment-aware, so
/api/productsdoes not match/api/products-archive. Printapi.pending()and compare againstpath, which is what patterns match — no origin, no query.
console.log(api.pending().map((r) => `${r.method} ${r.path}`));A canceled request throws when I don't want it to
throwOnCancel follows throwError, so a throwing client throws on cancel too. Split them:
createClient({ cancel: { throwOnCancel: false } }); // errors throw, cancels resolveKeep the default with TanStack Query and SWR — they only recognise failure through a rejected promise.
A canceled write left the server in a half-applied state
That is exactly why writes are not cancelable by default. Once the request is on the wire the server may commit it, and the client will never learn the outcome. If you opt a write in with cancelable: true, make the endpoint idempotent or reconcile on the next load.
Network request failed (statusCode: 0)
The request never reached the server. Causes, in order of likelihood:
- CORS — check the browser console for the real reason; the JS error is deliberately vague.
- Offline —
navigator.onLine. - Bad
baseUrl— logapiconfig, or watch the Network tab. - Mixed content — an
http:API from anhttps:page. - DNS / connection refused — the server isn't up.
const res = await api.get("/health", { throwError: false, skipAuth: true });
console.log(res.statusCode, res.message, res.error);Requests go to my own app instead of the API
Symptom: a GET /users shows up in the Network tab as https://my-app.com/users and returns your index.html (or a 404 from your own router) rather than hitting the API.
That means baseUrl resolved to "". Check what it actually resolved to:
import { detectBaseUrl } from "@mrzr/api-client";
console.log(JSON.stringify(detectBaseUrl())); // "" means nothing was foundCommon causes:
- The variable isn't exposed to the client. Browser bundlers only inline names with the right prefix.
API_URLworks on the server but is stripped from the browser bundle — useNEXT_PUBLIC_API_URL,VITE_API_URL,NUXT_PUBLIC_API_URLorPUBLIC_API_URL. - You used a custom variable name. Auto-detection only knows the names listed in [[Client Options]]. Pass it yourself:
createClient({ baseUrl: import.meta.env.MY_API }). - Config is loaded at runtime, after the bundle was built. Set
globalThis.__API_BASE_URL__before creating the client, or passbaseUrlexplicitly. - The dev server wasn't restarted after editing
.env. Most bundlers only read it at startup.
Being explicit always beats detection:
export const api = createClient({ baseUrl: import.meta.env.VITE_API_URL });Fixed in v1.0.2. Earlier versions only read env vars through a dynamic
process.env[key]index, which bundlers cannot inline and browsers don't have — so auto-detection always returned""on the client, and in worker mode regardless of environment.
Worker not initialized
A request was made before the worker signalled ready, or after destroy(). The client awaits readiness internally, so this almost always means the client was destroyed and then reused. Create a new one.
Client destroyed
Pending promises reject with this when destroy() is called. Make sure you aren't destroying a shared module-level client from a component unmount.
Behaviour problems
I fixed a bug / upgraded, but the old behaviour is still there
Almost always a stale or missing build rather than the bug itself.
dist/ is git-ignored and generated. Packing or linking without building ships
whatever was there last — or nothing at all. Check what you actually installed:
grep -c storageResult node_modules/@mrzr/api-client/dist/index.js
# 0 → stale build predating the storage fixThe build runs from the prepare hook, so npm link, npm pack and folder
installs all rebuild automatically. But prepare runs once, at link time —
if you're linked and editing the library, keep npm run dev running or the
consumer keeps seeing the build from when you linked.
Then clear stale copies:
rm -rf node_modules/.vite .next/cache # bundler caches hold the old moduleWorker mode: relative URLs fail (Failed to parse URL)
A Blob worker's base URL is blob:http://your-origin/uuid, and relative paths
cannot resolve against it — unlike on the main thread, where they resolve
against the page origin.
Fixed in v1.0.2: the host now passes the page origin to the worker, so relative URLs behave identically in both modes. On earlier versions the workaround was:
baseUrl: import.meta.client ? window.location.origin : config.public.apiUrl,which is no longer needed — and was a footgun during SSR, where window is
undefined.
Cookie mode: isAuthenticated is always false
With authMode: "cookie" the tokens are httpOnly, so JS cannot read them. The
client only learns about a session from the server's responses.
-
Right after login it should now be
true— a 2xx fromlogin()marks the session active. If it isn't, upgrade: before v1.0.2isAuthenticatedrequired a readable access token, so cookie mode reportedfalseforever. -
On a fresh page load it starts
falseby design. The cookie is there, but invisible. Ask the server once on startup:const state = await api.restoreSession("/api/auth/me"); -
If it flips to
falseunexpectedly, a request returned 401/403 after the refresh flow. That is the server rejecting the cookie — check that it is actually being sent (credentials: "include", and for a cross-origin API,Access-Control-Allow-Credentials: truewith an explicit origin).
User is logged out after every page reload
First, check whether the tokens are being written at all — look for apiclient.tokens in DevTools → Application → Local Storage (or Cookies).
If nothing is stored:
-
storagedefaults to"memory", which is designed not to survive a reload. Set it explicitly:createClient({ storage: "local" }); -
On v1.0.1 and earlier this was a bug: in worker mode (the default)
"local","session"and"cookie"all silently discarded every write, because the worker built the adapter in a scope with nolocalStorage. Upgrade to v1.0.2+, where the main thread owns the adapter.
If the tokens are stored but you're still logged out:
- Your server may not return them in a shape the default extractor recognises. Supply
extractTokens— but note that opts out of worker mode. - Check
storageKey: two clients with different keys don't share a session. - In
authMode: "cookie", nothing is stored locally by design; the browser holds httpOnly cookies and the session depends on those, not onstorage.
Confirm the round trip:
await api.login({ email, password });
console.log(await api.getAuthState()); // isAuthenticated: true
// reload, then:
console.log(await api.getAuthState()); // should still be trueNuxt: tokens don't persist
createClient runs on both server and client in Nuxt. On the server there is no localStorage, so create the client in a client-side plugin — or guard on import.meta.client — and keep one shared instance rather than one per component:
// plugins/api.client.ts
export default defineNuxtPlugin(() => {
const api = createClient({
baseUrl: useRuntimeConfig().public.apiUrl,
storage: "local",
});
return { provide: { api } };
});Creating a fresh client on every render also loses the session, because each one hydrates independently.
For SSR that needs the token on the server too, use authMode: "cookie" with httpOnly cookies, or the "cookie" adapter so the server can read the record.
Logging out in one tab doesn't affect the others
Check, in order:
multiTab: falsein your options.storage: "memory"(the default) — tabs share no storage, so a login in tab A can't be adopted by tab B. Logout still propagates. Use"local"or"cookie"for full sync.- Different origins or different
storageKeyvalues — neither shares a channel. - v1.0.1 and earlier: cross-tab sync was silently dead in worker mode (the default), because the worker scope has no
windowand was misdetected as SSR. Upgrade to v1.0.2+.
api.isWorker is false in the browser
Check, in order:
worker: falsein your options.extractTokensorbuildRefreshBodysupplied — functions can't cross the boundary.- CSP blocking
blob:— addworker-src 'self' blob:. - SSR — expected;
windowis undefined.
console.log({
isWorker: api.isWorker,
hasWorkerAPI: typeof Worker !== "undefined",
hasBlobURL: typeof URL?.createObjectURL === "function",
});Tokens vanish on reload
storage: "memory" is the default and is intentionally non-persistent. Options:
createClient({ storage: "local" }); // persists, but readable by page scriptsBetter: keep "memory" and mint a fresh access token on boot from a long-lived httpOnly refresh cookie:
await api.refresh();See [[Security Model]].
Refresh fires on every request
Your token looks perpetually near-expiry. Likely causes:
expiresAtreturned in seconds where ms is expected. The default extractor disambiguates values below1e12, but a customextractTokensmust do so itself.- Server clock skew larger than
refreshSkewMs. - The refresh endpoint returns an already-expired token.
console.log(new Date((await api.getAuthState()).expiresAt ?? 0).toISOString());Refresh never fires
- Opaque token — no readable JWT
exp, so the client can't predict expiry. Expected; the 401 path still works. SupplyexpiresAtexplicitly insetTokensif you know it. refreshSkewMs: 0disables the proactive path.- No refresh token stored — check
login()actually extracted one. - Cookie mode — proactive refresh is skipped by design.
Immediately logged out after logging in
extractTokens returned null for your response shape. Log the raw body:
const res = await api.post("/auth/login", creds, { skipAuth: true, fullData: true });
console.log(JSON.stringify(res.data, null, 2));Then write a matching extractor:
createClient({
extractTokens: (b: any) => ({ accessToken: b?.result?.jwt, refreshToken: b?.result?.renew }),
worker: false, // required: functions can't cross the worker boundary
});401 loop
Your refresh endpoint is itself returning 401, and the client is retrying it. Mark your auth calls:
await api.post("/auth/verify", body, { refreshTokenCheck: false, skipAuth: true });The built-in refresh() never recurses (it uses raw fetch), so a loop means a manual call is involved.
res.data is the whole envelope, not the payload
Unwrapping only happens when the body has a top-level data key. If your server returns { payload: … }, unwrap yourself:
await api.get("/users", { afterFunc: (d: any) => d.payload });Conversely, if you want the wrapper, pass fullData: true.
res.data is undefined on success
Legitimate cases: a 204, an empty body, or a non-JSON response that failed to parse. Check res.headers?.["content-type"] and res.statusCode.
Field errors are missing
The client reads errors from the top level of the response body:
{ "message": "Validation failed", "errors": { "email": ["taken"] } }Nested elsewhere? Map them:
catch (e) {
if (e instanceof ApiError) {
const errors = (e.data as any)?.detail?.fields ?? e.errors;
}
}Multi-tab sync isn't working
multiTab: falseset.- No
BroadcastChannel(old Safari) — the client degrades to single-tab. - Different
storageKeys across your clients means different channels — that may be intentional. - Different origins never share a channel;
localhost:3000andlocalhost:3001are separate.
Watch the traffic:
new BroadcastChannel("apiclient.auth").onmessage = (e) => console.log(e.data);A Node process won't exit
BroadcastChannel is a ref'd handle. The client never opens one on the server, but if you constructed a client in a browser-like test environment, call destroy():
afterEach(() => api.destroy());Also pass worker: false, multiTab: false in tests.
CSRF header isn't being sent
Checklist:
- Is the method unsafe?
GETnever gets the header, by design. - Is
xsrfCookieNameorgetCsrfTokenconfigured? - Is the cookie readable (i.e. not httpOnly)?
- In worker mode, does
getCsrfTokenwork on the main thread? - Did you set the header explicitly per-request? Yours wins.
console.log(document.cookie); // is the cookie there?
createClient({ getCsrfToken: () => { const t = read(); console.log("csrf:", t); return t; } });Cookies aren't sent cross-origin
Requires all of:
createClient({ authMode: "cookie" }); // sets credentials: "include"Set-Cookie: session=…; HttpOnly; Secure; SameSite=None
Access-Control-Allow-Origin: https://app.example.com (never *)
Access-Control-Allow-Credentials: trueSameSite=None requires Secure, which requires HTTPS — including in local development.
Uploads arrive as {"0":72,"1":105}
You're on an old version, or you converted the body yourself. Current versions detect typed arrays via ArrayBuffer.isView(). Verify:
await api.post("/upload", new Uint8Array([72, 105]), {
headers: { "Content-Type": "application/octet-stream" },
});Content-Type is wrong on an upload
Precedence: per-request header → non-JSON client header → the body's own type. If a stale application/json is sticking, you probably set it client-wide. Override per request:
await api.post("/upload", form, { headers: { "Content-Type": "" } });Or drop it from the client defaults and set it only where you send JSON.
Debug checklist
const api = createClient({
baseUrl,
onLog: (e) => console.log("[api]", e),
onError: (r) => console.error("[api error]", r),
onAuthStateChanged: (s) => console.log("[auth]", s),
onAuthFailure: () => console.warn("[auth] failed"),
});
console.log("worker:", api.isWorker);
console.log("state:", await api.getAuthState());
await api.get("/health", { log: true, skipAuth: true, throwError: false });Then check the Network tab for the actual request URL, headers and response — the client is a thin layer over fetch, so the truth is always visible there.
Still stuck?
Open an issue with:
- Package version, runtime and framework
- Your
createClientoptions (with secrets removed) - The failing call and the full
IRes/ApiError api.isWorker- A minimal reproduction, ideally against
https://httpbin.org
Next: [[FAQ]]