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.
You don't write refresh logic. The client refreshes in two situations:
- Before expiry. If the access token is a JWT that expires within
refreshSkewMs(default 30 s), the client refreshes it before sending the next request. - After a 401. If the server answers 401, the client refreshes and retries the request once with the new token.
Either way, requests that arrive during a refresh wait for it. Many 401s at once produce one refresh request, not one per request:
One refresh for many requests
Expire the token, then send 8 requests at the same time. Open DevTools → Network to watch it happen.
- 1.The access token is replaced with an expired one.
- 2.8 requests go out at once. The server answers each with 401.
- 3.The client pauses them and sends a single refresh request.
- 4.All 8 requests are retried with the new token and succeed.
Refresh requests sent: –
Tabs that share a session take turns refreshing through the Web Locks API, so a rotating refresh token is never spent twice.
When each one runs
The 401 retry happens once per request. A second 401 is returned as-is, so a broken server can't cause a loop. The retry gets a fresh timeout. It's skipped for skipAuth: true and refreshTokenCheck: false.
The early refresh only runs when all of these are true:
refreshSkewMsis above0(0turns it off and relies on 401s)- the client is in header mode (in cookie mode it can't read the expiry)
- both an access token and a refresh token are stored
- the token's expiry is known and falls inside the window
An opaque (non-JWT) token has no readable expiry, so it's never refreshed early; the 401 path still covers it. If you know the expiry, pass expiresAt in the login response or to setTokens.
The refresh request
By default the client sends:
POST /auth/refresh
Content-Type: application/json
{ "refresh": "<refresh token>" }and reads the new tokens from the response the same way it reads the login response. It carries the CSRF header like any write, and it's bounded by the client timeout.
If the response has a new refresh token, it replaces the old one (refresh-token rotation). If it has only an access token, the old refresh token is kept.
Custom token shapes
If your server uses other field names, describe them:
createClient({
// response: { result: { jwt: "…", renew: "…", expires_in: 900 } }
extractTokens: {
accessKeys: ["jwt"],
refreshKeys: ["renew"],
expiresInKeys: ["expires_in"], // seconds from now
expiryKeys: ["exp_at"], // or an absolute time, in seconds or ms
roots: ["result"],
},
// request body: { "refresh_token": "…" }
buildRefreshBody: { field: "refresh_token" },
});The same extractTokens is used for login and for refresh. Both options also accept a function, but a function can't be sent into the Web Worker, so passing one turns worker isolation off. Prefer the object forms above.
When refresh fails
| Refresh endpoint answers | Result |
|---|---|
| 401 or 403 | The session is cleared and onAuthFailure fires. Tabs sharing the session ("local" or "cookie" storage) are logged out too |
| 5xx, timeout, network error | The session is kept. The original request fails, and the next one tries again |
| 2xx without tokens, cookie mode | Success: the server rotated the httpOnly cookies |
| 2xx without tokens, header mode | Treated as a rejection: the session is cleared |
A server hiccup never logs the user out.
Manual refresh and long uploads
const ok = await api.refresh(); // true when the session is usable again
// A 10-minute upload: refresh first if the token dies within 10 minutes.
await api.post("/upload", file, { uploadSkewMs: 10 * 60_000 });refresh() returns a boolean, not the token: the token never leaves the worker. Manual calls join any refresh already in flight, so it's safe to call from a heartbeat. In header mode with no refresh token stored it returns false without a request.
uploadSkewMs works even when refreshSkewMs is 0, because it states what this one request needs.
To skip the 401 → refresh → retry flow for one call, pass refreshTokenCheck: false. Do that for auth calls you make yourself (OTP checks, token exchanges), so a failing one can't trigger a refresh.
JWT helpers
The expiry helpers the client uses are exported:
import { getTokenExpiry, isTokenExpired } from "@mrzr/api-client";
getTokenExpiry(token); // epoch ms, or null if it isn't a JWT with an exp claim
isTokenExpired(token); // false when the expiry is unknown
isTokenExpired(token, 60_000); // will it be expired a minute from now?They only decode. They never verify the signature: that's your server's job.
Debugging
| Symptom | Likely cause |
|---|---|
| Refresh runs before every request | The server returns an already-expired token, expiresAt is in seconds where ms is expected, or client and server clocks differ by more than refreshSkewMs |
| Refresh never runs early | Opaque token, refreshSkewMs: 0, no refresh token stored, or cookie mode. All expected; 401s still refresh |
| Logged out right after logging in | extractTokens finds no tokens in your response. Log it with api.post(loginUrl, creds, { skipAuth: true, fullData: true }) |
| 401 loop | A call you make to an auth endpoint returns 401. Add refreshTokenCheck: false to it |
api.onAuthStateChange((s) => console.log(s.expiresAt && new Date(s.expiresAt))) shows each refresh as it happens.
Authentication
Log users in with bearer tokens or httpOnly cookies. Choose where tokens are stored, restore a cookie session on page load and enable CSRF protection.
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.