How I implemented proactive + reactive token refresh with single-flight in tanstack-fetch 1.3.0
If you've ever implemented access-token refresh, you've probably seen this:
Request A → 401 → refresh
Request B → 401 → refresh
Request C → 401 → refresh
Request D → 401 → refresh
Four requests.
Four refresh calls.
One access token.
This is one of those problems that looks easy until multiple requests fail at the same time.
With tanstack-fetch 1.3.0, I added createRefreshTokenInterceptor to handle both sides of token refresh:
- Proactive: refresh shortly before the access token expires.
-
Reactive: refresh after the first
401. - Single-flight: concurrent requests share the same refresh operation.
Let's look at how it works.
What is tanstack-fetch?
tanstack-fetch is a typed Fetch client designed to work naturally with TanStack Query.
Its core contract is deliberately simple:
HTTP 2xx → return data
HTTP error → throw FetchError
request cancelled → respect AbortSignal
So a query can stay as simple as:
useQuery({
queryKey: ['users'],
queryFn: ({ signal }) =>
api.get('/users', { signal }),
})
There is no .data wrapper and no Axios adapter layer.
The HTTP core is around 3.5 KB gzipped, while SSR, SSE, uploads, React helpers, and tRPC support are available through optional entry points.
tanstack-fetch is an independent package, not an official TanStack project.
The two refresh strategies
There are two common ways to deal with an expiring access token.
1. Refresh before expiry
If we know when the token expires, we can refresh it before sending the request.
For example:
before: {
getExpiresAt: () => expiresAt,
skewMs: 60_000,
}
This means:
expiresAt - 60 seconds
↓
refresh
↓
send request
The request gets the new token instead of sending an almost-expired one.
2. Refresh after a 401
Expiration timestamps aren't always perfect.
The server may invalidate a token early, clocks may differ, or the token might have expired between checking it and sending the request.
That's why the interceptor also supports reactive refresh:
Request
↓
401
↓
refresh
↓
retry request
The retry happens only for the first unauthorized attempt.
The important part: single-flight refresh
This is where things usually get messy.
Imagine 10 TanStack Query requests are running at the same time.
The token expires.
All 10 requests receive 401.
A naive implementation can produce:
10 requests
↓
10 × /auth/refresh
That's unnecessary and can cause even worse problems if the refresh token is rotated.
Instead, createRefreshTokenInterceptor makes refresh single-flight:
Request 1 ─┐
Request 2 ─┤
Request 3 ─┤
Request 4 ─┼──→ refresh()
Request 5 ─┤ │
Request 6 ─┤ ↓
Request 7 ─┤ new access token
Request 8 ─┘ │
↓
retry requests
Multiple requests can wait for the same refresh operation.
Only one refresh request is made.
The setup
Here's the complete example:
import { createFetch } from 'tanstack-fetch'
import { createRefreshTokenInterceptor } from 'tanstack-fetch/plugins'
let accessToken = localStorage.getItem('access_token')
let expiresAt = Number(
localStorage.getItem('access_expires_at') ?? 0,
)
const persist = (
token: string,
expiresInSeconds: number,
) => {
accessToken = token
expiresAt =
Date.now() + expiresInSeconds * 1000
localStorage.setItem(
'access_token',
accessToken,
)
localStorage.setItem(
'access_expires_at',
String(expiresAt),
)
}
export const api = createFetch({
baseUrl: import.meta.env.VITE_API_URL,
getToken: () => accessToken,
onUnauthorized: () => {
localStorage.removeItem('access_token')
localStorage.removeItem('access_expires_at')
window.location.href = '/login'
},
})
api.use(
'refresh-token',
createRefreshTokenInterceptor({
refresh: async () => {
const response = await fetch(
'/auth/refresh',
{
method: 'POST',
credentials: 'include',
},
)
if (!response.ok) {
throw new Error('refresh failed')
}
const body = (await response.json()) as {
accessToken: string
expiresIn: number
}
persist(
body.accessToken,
body.expiresIn,
)
},
// Proactive refresh
before: {
getExpiresAt: () => expiresAt,
skewMs: 60_000,
},
// Reactive refresh
after: {
enabled: true,
},
}),
)
That's basically it.
How the interceptor behaves
The two options have different responsibilities:
| Strategy | Trigger | Action |
|---|---|---|
before |
Token is inside the expiry window | Refresh before request |
after |
First 401
|
Refresh and retry |
| single-flight | Multiple refreshes happen concurrently | Share one refresh operation |
This also means you can choose only the behavior you need.
Only reactive refresh
createRefreshTokenInterceptor({
refresh,
after: {
enabled: true,
},
})
Only proactive refresh
createRefreshTokenInterceptor({
refresh,
before: {
getExpiresAt: () => expiresAt,
skewMs: 60_000,
},
after: false,
})
Both
createRefreshTokenInterceptor({
refresh,
before: {
getExpiresAt: () => expiresAt,
skewMs: 60_000,
},
after: {
enabled: true,
},
})
For most applications, having both gives you a useful fallback:
token nearly expired?
│
┌──────┴──────┐
│ │
yes no
│ │
refresh send request
│ │
└──────┬──────┘
↓
API
│
401?
│
┌──────┴──────┐
│ │
no yes
│ │
done refresh once
│
↓
retry
What if refresh fails?
The refresh interceptor doesn't need to own your logout logic.
If refreshing fails, the request can continue through your normal unauthorized handling.
For example:
onUnauthorized: () => {
localStorage.removeItem('access_token')
localStorage.removeItem('access_expires_at')
window.location.href = '/login'
}
This keeps responsibilities separate:
Refresh interceptor
Can this request be recovered with a new token?
Auth layer
Can this user continue their session?
TanStack Query
What should happen to the query state?
I prefer this separation because authentication recovery doesn't end up duplicated across every query or page.
Why not put this inside useQuery?
You could do something like this:
useQuery({
queryKey: ['users'],
queryFn: async () => {
try {
return await api.get('/users')
} catch (error) {
// refresh?
// retry?
// logout?
}
},
})
But then you'll eventually repeat the same logic across multiple queries.
The HTTP layer is a better place for HTTP authentication concerns.
Your query remains:
useQuery({
queryKey: ['users'],
queryFn: ({ signal }) =>
api.get('/users', { signal }),
})
And authentication recovery happens below it.
Other recent improvements
Path-typed parameters — 1.2.1
Path parameters can be inferred directly:
api.get('/users/:id', {
params: {
id: userId,
},
})
Required parameters are inferred from :param and {param} patterns.
First-class 4xx handlers — 1.2.0
The client also includes handlers for common HTTP cases:
onUnauthorized
onTooManyRequests
onClientError
parseRetryAfter
This keeps HTTP error behavior centralized instead of spreading status-code checks throughout your application.
Documentation
I've also been improving the documentation around the project.
It now includes:
- a clearer homepage
- live comparison examples
- StackBlitz examples
llms.txt- a dedicated refresh-token recipe
Install
npm install tanstack-fetch @tanstack/react-query
Then:
import { createFetch } from 'tanstack-fetch'
import { useQuery } from '@tanstack/react-query'
const api = createFetch({
baseUrl: 'https://api.example.com',
})
useQuery({
queryKey: ['users'],
queryFn: ({ signal }) =>
api.get('/users', { signal }),
})
Links
📦 npm
https://www.npmjs.com/package/tanstack-fetch
📚 Documentation
https://mohamadgarmabi.github.io/tanstack-fetch/
🔐 Refresh Token Recipe
https://mohamadgarmabi.github.io/tanstack-fetch/recipes/refresh-token
💻 GitHub
https://github.com/mohamadgarmabi/tanstack-fetch
📝 Changelog — 1.3.0
https://github.com/mohamadgarmabi/tanstack-fetch/blob/main/CHANGELOG.md
Final thought
Token refresh is easy when you have one request.
It's much more interesting when 10 requests fail simultaneously.
The goal of createRefreshTokenInterceptor is to make that case boring:
- Refresh before expiry when possible.
- Recover after a
401when necessary. - Share concurrent refresh operations.
- Retry the original request once.
- Let the application decide what happens when recovery fails.
If you're using TanStack Query and you've implemented your own refresh queue, I'd be interested in how you handled it.
What edge cases should createRefreshTokenInterceptor handle next?
Issues and PRs are welcome.
Top comments (0)