I've been working on a small library to solve a problem I kept running into: handling JWT token refresh properly without framework lock-in or unnecessary complexity.
The Problem
If you've worked with JWT authentication, you know the drill:
- Access token expires mid-session
- API returns 401
- You need to refresh the token and retry the original request
- But wait - what if 5 requests fail at the same time? Now you're making 5 refresh calls
- And what about other browser tabs? They're still using the old token
Most solutions I found were either tightly coupled to Axios interceptors, required a specific state management library, or simply didn't handle the edge cases properly.
The Solution: ts-retoken
I built ts-retoken - a zero-dependency TypeScript library that wraps the native fetch API and handles all the token refresh complexity for you.
Key Features
1. Automatic Token Refresh
When a request returns 401, the library automatically refreshes the token and retries the original request. Your application code doesn't need to know anything about token expiration.
2. Request Queue (Race Condition Prevention)
This is the big one. If 10 requests fail simultaneously because the token expired, the library:
- Queues all 10 requests
- Makes exactly ONE refresh call
- Retries all 10 requests with the new token
No more duplicate refresh calls or race conditions.
3. Cross-Tab Synchronization
Using the BroadcastChannel API, token refresh is synchronized across all browser tabs. If Tab A refreshes the token, Tab B immediately gets the new token without making its own refresh call.
4. Framework Agnostic
Works with React, Vue, Svelte, vanilla JS - anything that can run TypeScript/JavaScript. It's just a wrapper around fetch.
5. Full TypeScript Support
Generic types let you define your refresh response shape, and you get full autocomplete and type checking throughout.
Quick Start
npm install ts-retoken
import { createRetoken } from 'ts-retoken';
const retoken = createRetoken({
refreshEndpoint: {
url: '/api/auth/refresh',
parseResponse: (data) => ({
accessToken: data.access_token,
refreshToken: data.refresh_token,
}),
},
getAccessToken: () => localStorage.getItem('access_token'),
getRefreshToken: () => localStorage.getItem('refresh_token'),
setTokens: (tokens) => {
localStorage.setItem('access_token', tokens.accessToken);
localStorage.setItem('refresh_token', tokens.refreshToken);
},
clearTokens: () => localStorage.clear(),
onAuthFailure: () => {
// Redirect to login when refresh fails
window.location.href = '/login';
},
});
// Use it just like fetch - token management is automatic
const response = await retoken.fetch('/api/users');
const users = await response.json();
Advanced Usage
Custom Headers & Cookie-based Auth
const retoken = createRetoken({
refreshEndpoint: {
url: '/api/auth/refresh',
method: 'POST',
headers: { 'X-Custom-Header': 'value' },
credentials: 'include', // For cookie-based refresh tokens
parseResponse: (data) => ({
accessToken: data.access_token,
refreshToken: undefined, // Cookie-based, no refresh token in response
}),
},
// ... other options
});
React Router Integration
Since callbacks are captured at creation time, you need a ref pattern to use React hooks like useNavigate:
// lib/auth.ts
export const authCallbacks = {
onAuthFailure: () => {},
};
export const retoken = createRetoken({
// ... config
onAuthFailure: () => authCallbacks.onAuthFailure(),
});
// AuthProvider.tsx
export function AuthProvider({ children }) {
const navigate = useNavigate();
useEffect(() => {
authCallbacks.onAuthFailure = () => navigate('/login');
return () => { authCallbacks.onAuthFailure = () => {}; };
}, [navigate]);
return <>{children}</>;
}
Cross-Tab Sync Configuration
const retoken = createRetoken({
// ... other options
crossTab: {
enabled: true,
channelName: 'my-app-auth', // Custom channel name
},
});
How It Works Under the Hood
-
Intercept: Every
fetchcall goes through the wrapper - Detect: If response is 401, trigger refresh flow
- Queue: Lock the refresh state, queue any concurrent requests
- Refresh: Make single refresh call to your endpoint
- Broadcast: Sync new tokens across tabs via BroadcastChannel
- Retry: Replay all queued requests with new token
-
Fallback: If refresh fails, call
onAuthFailureand reject all queued requests
Why Not Just Use Axios Interceptors?
You totally can! But here's why I went with a custom solution:
- Native fetch: No additional HTTP library dependency
- Simpler mental model: One function that wraps fetch, that's it
- Cross-tab sync built-in: Axios interceptors don't handle this
- Request queuing by default: Many interceptor implementations miss this
Performance & Bundle Size
- Zero runtime dependencies
- ~3KB minified + gzipped
- Tree-shakeable ES modules
Links
- GitHub: https://github.com/vanthao03596/ts-retoken
-
npm:
npm install ts-retoken
I'd love to hear your feedback! Are there any features you'd want to see? Any edge cases I might have missed? Let me know in the comments.
Thanks for reading!
Top comments (0)