The Bug That Kept Logging Me Out
I was building a PWA with a custom Node.js backend and Supabase auth.
Everything worked fine — until users (me) kept getting randomly logged out
for no obvious reason.
No errors. No warnings. Just suddenly back at the login screen.
What Was Actually Happening
Most JWT auth setups use two refresh strategies:
Proactive — a timer fires ~1 minute before the access token expires and
refreshes it silently in the background.
Reactive — an Axios interceptor catches 401 errors and refreshes the token
when a request fails.
The problem: both fired at the same time.
Here's the exact sequence:
- Proactive timer fires → sends refresh token to backend
- An API call returns 401 simultaneously → interceptor also sends the same refresh token
- Backend receives two requests with the same refresh token
- First one succeeds → Supabase rotates the token, old one is now dead
- Second one fails with 401 → interceptor hits the failure path → clears localStorage → redirects to /login
User is logged out. No error. No warning. Just gone.
Why Existing Libraries Don't Fix This
I checked axios-auth-refresh and axios-auth-refresh-queue. Both solve
concurrent 401s — multiple requests failing at the same time.
But neither coordinates with a proactive timer. They only know about
requests going through Axios. Your timer fires outside of that.
The Fix
Both the proactive timer and the reactive interceptor need to share a
single lock. If one is already refreshing, the other should join a queue
and wait for the result — not fire a second request.
I extracted this pattern into a small package:
axios-refresh-sync
npm install axios-refresh-sync@latest
import { createRefreshManager } from 'axios-refresh-sync'
const manager = createRefreshManager({
axiosInstance: api,
refreshEndpoint: '/api/auth/refresh',
getAccessToken: () => localStorage.getItem('access_token'),
getRefreshToken: () => localStorage.getItem('refresh_token'),
setTokens: (accessToken, refreshToken) => {
localStorage.setItem('access_token', accessToken)
localStorage.setItem('refresh_token', refreshToken)
},
onRefreshFailed: () => window.location.href = '/login'
})
// Call after login or app init
manager.scheduleRefresh()
That's it. Both the timer and the interceptor now coordinate under one lock.
If one is mid-refresh, the other waits. Supabase only gets called once.
How It Works Internally
Each manager gets its own isolated lock instance containing:
-
acquireLock()— check if a refresh is already running -
setLock(true/false)— claim or release the lock -
enqueue()— join the waiting queue if locked -
flushQueue()— resolve or reject everyone waiting
Both proactive and interceptor modules read from the same lock instance.
Neither can race the other. Multiple managers don't share state.
Works With Any Backend
Despite being built with Supabase in mind, the package is completely
backend agnostic. As long as your refresh endpoint accepts a refresh token
and returns new tokens, it works.
Storage is also configurable — you bring your own get/set functions,
so localStorage, cookies, or anything else works.
Update: v0.2.0 — Multi-tab sync
One gap I noticed after publishing: if a token is refreshed in Tab A,
Tab B still had the stale token in memory and would try to refresh again.
v0.2.0 fixes this using the localStorage storage event — when one tab
refreshes the token, all other tabs detect it and flush their queues
automatically with the new token. No duplicate refresh requests across tabs.
Update: v0.3.0 — Production Ready
After some sharp feedback from the community, v0.3.0 fixes several real bugs:
🔴 Critical: Interceptor Recursion Bug (Fixed)
The refresh call was using the same axios instance that had the 401 interceptor
attached. If your refresh token expired, the interceptor would catch the 401 from
the refresh endpoint and try to refresh again — infinite loop.
Fix: The library now creates a bare axios instance internally for refresh calls
with no interceptors attached.
Instance Isolation (Fixed)
Two createRefreshManager() calls previously shared the same lock and timer
state. They would corrupt each other. Each manager now gets fully isolated state.
New: destroy() Method
const manager = createRefreshManager({ ... })
// On logout or component teardown:
manager.destroy() // clears timer, removes interceptor, removes storage listener
New: Custom Response Parser
createRefreshManager({
// If your backend returns different field names:
parseTokens: (data) => ({
accessToken: data.token,
refreshToken: data.refresh,
})
})
19 Tests Added
Full test suite covering concurrent 401s, timer vs interceptor race,
refresh failure, multi-instance isolation, and destroy cleanup.
Links
- npm: axios-refresh-sync
- GitHub: mk90909876-art/axios-refresh-sync
If you've ever had users mysteriously logged out and couldn't figure out why —
this might be it.
Top comments (0)