DEV Community

Pham Thao
Pham Thao

Posted on

Stop Writing Token Refresh Logic: Let ts-retoken Handle It

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:

  1. Access token expires mid-session
  2. API returns 401
  3. You need to refresh the token and retry the original request
  4. But wait - what if 5 requests fail at the same time? Now you're making 5 refresh calls
  5. 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
Enter fullscreen mode Exit fullscreen mode
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();
Enter fullscreen mode Exit fullscreen mode

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
});
Enter fullscreen mode Exit fullscreen mode

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}</>;
}
Enter fullscreen mode Exit fullscreen mode

Cross-Tab Sync Configuration

const retoken = createRetoken({
  // ... other options
  crossTab: {
    enabled: true,
    channelName: 'my-app-auth', // Custom channel name
  },
});
Enter fullscreen mode Exit fullscreen mode

How It Works Under the Hood

  1. Intercept: Every fetch call goes through the wrapper
  2. Detect: If response is 401, trigger refresh flow
  3. Queue: Lock the refresh state, queue any concurrent requests
  4. Refresh: Make single refresh call to your endpoint
  5. Broadcast: Sync new tokens across tabs via BroadcastChannel
  6. Retry: Replay all queued requests with new token
  7. Fallback: If refresh fails, call onAuthFailure and 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


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)