React Native token refresh: ten requests, one refresh
The companion video becomes viewable when its YouTube release goes public.
A screen starts several requests with an expired access token. They fail together. If every 401 handler exchanges the same refresh token independently, the client turns a recoverable access-token expiry into a refresh-token race.
The teaching scenario uses ten overlapping callers. It illustrates the mechanism; it is not a production traffic measurement.
Share one refresh across overlapping callers, persist the new tokens, and retry each request once.
This is Day 18 of Ship Native, a standalone Production fixes lesson. The implementation comes from Annia's shared API client, token store, chat stream and live connection. Its backend contract explains the rotation policy. Source was reviewed; no mobile app or live backend was run for this article. The isolated tests described below cover only the shared-promise mechanism.
Why duplicate refreshes matter
An access token authenticates ordinary requests. The refresh token obtains a replacement pair. Annia's contract rotates both tokens on a successful exchange. Presenting an already-used refresh token is treated as reuse and revokes that account's sessions across devices.
That consequence is specific to this contract. Other servers use different rotation windows, replay handling or session scopes. Read the backend rules before copying a failure policy.
The naive flow is:
Request A receives 401 -> exchange refresh token R0
Request B receives 401 -> exchange refresh token R0 again
Server rotates R0 on the first exchange
The later exchange presents a spent credential
The relevant unit of ownership is the session's refresh operation, shared by callers in the same JavaScript runtime.
Retain one pending operation
The source retains a module-level promise. This is the project's coordinator, with surrounding comments omitted:
let inFlight: Promise<TokenPair | null> | null = null;
export const refreshSession = (): Promise<TokenPair | null> => {
inFlight = inFlight ?? refresh();
return inFlight.finally(() => {
inFlight = null;
});
};
The first caller starts refresh(). Another caller arriving while it is pending joins the same underlying operation. Each .finally() call returns a wrapper promise; callers share the refresh work and result, not necessarily the identity of the promise returned to each caller.
This gate releases after settlement so a later refresh can run. It does not cache a completed result indefinitely, coordinate different JavaScript processes or suppress every 401 that arrives after the first refresh finishes.
Keep the refresh transport separate
The ordinary Axios instance has an auth response interceptor. Calling that same instance for the refresh endpoint could make a failed refresh enter its own refresh logic.
The source uses a separate bare Axios call:
const refresh = async (): Promise<TokenPair | null> => {
const pair = await readTokens();
if (!pair) return null;
try {
const { data } = await axios.post<TokenPair>(
`${API_URL}/auth/refresh`,
{ refreshToken: pair.refreshToken },
{ timeout: 20_000 },
);
await saveTokens(data);
return data;
} catch (error) {
if ((error as AxiosError).response?.status === 429) return null;
await clearTokens();
return null;
}
};
API_URL, the Axios types and token helpers are existing application imports. These excerpts describe the project implementation; they are not a standalone complete mobile client.
The refresh timeout shown is the source value, not a recommendation for every backend. The ordinary API instance has a different timeout because the application also handles long-running chat work.
Save the whole pair before retrying
The successful branch awaits saveTokens(data) before resolving the shared operation. Waiting callers can then read the replacement access and refresh tokens.
Annia's token store writes the pair through react-native-keychain, with a memory cache maintained by the read, save and clear helpers. A declared TypeScript response type does not validate network data at runtime; server response validation and secure-storage behavior deserve their own integration checks.
Updating only the access token would leave the old refresh credential available for the next exchange. Under this rotation contract, both must change.
Retry the original request once
The source marks a retryable configuration before entering the refresh path:
type Retryable = InternalAxiosRequestConfig & { _retried?: boolean };
if (status === 401 && config) {
if (config._retried) {
await endSession();
} else {
config._retried = true;
const pair = await refreshSession();
if (pair) {
config.headers.Authorization = `Bearer ${pair.token}`;
return api(config);
}
await endSessionIfGone();
}
}
This is an excerpt from the response interceptor. status and config are derived from the Axios error. The rest of that interceptor normalizes and rejects the error when recovery does not return a response.
Marking the request before retry prevents that request from repeatedly refreshing. A second 401 after the successful refresh ends the session under this application's policy.
Replaying a request also requires the API to support it safely. For a mutation, verify that authentication rejects the first call before side effects or that the endpoint uses a suitable idempotency mechanism. A token-refresh gate is not a mutation deduplication system.
Share it beyond Axios
The chat stream uses raw XHR for incremental reads. Axios interceptors never see it. Its 401 handler calls the exported refreshSession(), then opens the stream again once with the new access token. An aborted stream checks its cancellation state before reopening.
The live WebSocket connection also uses this coordinator when its pair is nearly expired before connecting. That prevents each transport from inventing an independent refresh owner inside the same runtime.
There is still transport-specific work: deciding when to retry, preserving request state, handling abort and deciding when to end the session.
Throttling and a lost response are different
The source returns null on refresh 429 while preserving the stored pair. Its comment assumes that throttling happened before the server consumed or rotated the token. The documentation supplies a Retry-After delay, but the current coordinator does not wait for it or automatically retry.
The original API request still fails. endSessionIfGone() reads storage and avoids signing out merely because a throttled refresh returned no replacement pair. A complete UI policy may need to surface throttling and its delay explicitly; null alone cannot express all those reasons.
Other refresh failures clear the pair in this implementation, including an ambiguous network failure. The server may have rotated the token while its response was lost. The contract instructs the client to sign in again instead of blindly replaying that exchange.
This conservative policy is app-specific. Confirm that your server distinguishes a request rejected before rotation from a refresh whose outcome is unknown.
What the isolated checks prove
The accompanying sample keeps the same pending-promise algorithm and injects a controlled refresh operation. It uses fake token strings and no real network or storage.
node --test day-18/source/refresh-gate.test.mjs
The tests cover ten overlapping callers starting one operation, a new refresh after successful settlement, release after rejection and a shared null result followed by another attempt. Read the validation report for actual results.
These tests do not verify Axios interceptors, the Keychain adapter, the backend's rotation behavior, mobile networking or the application's UI. They also do not establish that the full production client prevents every auth race.
Remaining race boundaries
- A stale response that arrives after the shared operation settles may start another refresh. Consider request-token or session-generation comparison when testing that case.
- Logout or account switching while refresh is pending needs its own generation/cancellation policy so an older result cannot restore an ended session.
- Separate runtimes or processes need coordination beyond this module variable.
- A storage write failure and invalid refresh response require explicit recovery behavior.
- A mutation retry must preserve the endpoint's side-effect guarantees.
- The current 429 branch preserves credentials but does not implement the documented waiting policy.
These are review boundaries, not claims that those scenarios were observed as failures in production.
Verification checklist
- Start overlapping callers while a controlled refresh remains pending; count exchange starts.
- Confirm all joined callers receive the replacement pair.
- Confirm saving completes before callers resume their requests.
- Verify the refresh endpoint cannot recurse through the ordinary auth interceptor.
- Return 401 on the replay and confirm the original request does not loop.
- Exercise throttling and the UI's delayed retry behavior separately.
- Simulate a lost response after server rotation under the actual backend contract.
- Exercise Axios, raw XHR and the live connection together.
- Test late 401 responses, logout, account switching and secure-storage failure.
- Confirm mutation replay/idempotency behavior on the server.
References and series
- Axios: interceptors.
- Axios: calling an instance with an existing configuration.
- Previous: Day 17: Crashlytics installed does not mean reporting. That companion article is currently a draft.
- Next: a debugging or owner-narrated AI lesson selected later.
This article is prepared as a draft. Add its actual video embed after the upload URL is supplied, and set the date to the real publication date before publishing.
Video companion
The full lesson and Short are scheduled for October 12, 2026 at 16:00 Africa/Cairo (13:00 UTC).
- Full video: https://youtu.be/jwFNjoP6iLk
- Short: https://youtube.com/shorts/CUhQ950NXlM
Comments