Token refresh
Silent refresh on 401.
Access tokens expire after 15 minutes. Without refresh, the user gets logged out every 15 minutes β terrible UX. With refresh, they're logged in for a week. This lesson covers the pattern.
The flow
Silent refresh sequence
The whole dance happens silently. The user doesn't see a thing.
The fetch interceptor pattern
async function authedRequest<T>(method: string, path: string, body?: unknown): Promise<T> {let { access, refresh } = await loadTokens()const doFetch = (token: string) => fetch(`${API_URL}${path}`, {method,headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },body: body ? JSON.stringify(body) : undefined,})let res = await doFetch(access!)// If access token is dead, try to refresh onceif (res.status === 401 && refresh) {const r = await fetch(`${API_URL}/api/auth/refresh`, {method: 'POST',headers: { 'Content-Type': 'application/json' },body: JSON.stringify({ refresh_token: refresh }),})if (r.ok) {const json = await r.json()await saveTokens(json.data.access_token, json.data.refresh_token)res = await doFetch(json.data.access_token)}}const json = await res.json()if (!res.ok) throw new ApiError(json.error?.code, json.error?.message, res.status)return json as T}
One interceptor, handles every authed call. If refresh succeeds, retry; if it fails, the next 401 propagates and the AuthProvider kicks the user to /login.
Single-flight: don't refresh 10x in parallel
If 10 React Query queries all fire simultaneously and all return 401, you don't want 10 refresh calls β that creates 10 new access tokens and 9 are wasted (and refresh-token rotation makes 9 of them stale). Use a promise lock:
let refreshing: Promise<{ access: string; refresh: string }> | null = nullasync function refreshTokens() {if (refreshing) return refreshingrefreshing = doRefresh().finally(() => { refreshing = null })return refreshing}
Every concurrent call hits the same promise β one network refresh, all callers get the new token.
What happens when refresh fails
Two cases:
- Network error β show a toast, let the user retry. Don't log them out.
- 401 from /refresh β refresh token is expired or revoked. Clear tokens, send to
/login.
React Query plays nicely
React Query's built-in retry kicks in if a query throws. With the interceptor doing the refresh, you usually want retry: false on auth-required queries β the interceptor already retried; a second retry is wasted.
Quick check
Try it
Verify your refresh works end-to-end:
- Set your API's
JWT_ACCESS_EXPIRYto30sfor testing (in.env). - Restart the API.
- Sign in on the mobile app.
- Wait 35 seconds. Then tap something that fires a query.
- The query should silently refresh + retry; you see data, not a login screen.
- Open Metro's network inspector β you should see one POST to
/api/auth/refreshsandwiched between the two attempts.
Paste the network inspector output in notes.md. Don't forget to revert JWT_ACCESS_EXPIRY to 15m after.
What's next
Chapter 4 β Push notifications. Register for push, save the token to your Grit API, send a push from a job worker.
Spot a typo? Have an idea?
Help us improve this lesson. One click opens a GitHub issue with the lesson URL pre-filled β suggest clearer wording, report a bug, or request more depth. The course keeps improving thanks to learners like you.
Suggest an improvement on GitHub