Skip to main content

Automatic Token Refresh System Design

Comprehensive low-level design for implementing automatic JWT token refresh in frontend applications with error handling and concurrency control

This document presents a robust low-level design for automatic token refresh mechanisms in modern web applications, covering implementation strategies, edge cases, and security considerations for seamless user experiences.

1. Problem Statement & Requirements

Clarifying questions

  1. What authentication method is being used (JWT, OAuth, Session-based)?
  2. Do we need to support refresh tokens in local storage or session storage?
  3. Should token refresh happen automatically in the background or require user interaction?
  4. What is the expected behavior when refresh tokens expire or are invalidated server-side?
  5. Do we need to support concurrent requests during token refresh (e.g., multiple API calls while refresh is in progress)?
  6. Are there specific browser support requirements (e.g., mobile browsers, IE11 compatibility)?
  7. Should the system handle token refresh for third-party API calls or only internal services?

Functional requirements

  • Automatically refresh access tokens before they expire
  • Handle concurrent API requests during token refresh
  • Retry failed requests with new tokens when refresh succeeds
  • Gracefully handle refresh token expiration or invalidation
  • Support multiple authentication providers (if applicable)
  • Maintain session state across page reloads
  • Provide hooks or utilities for token-aware API calls

Non-functional requirements

  • Performance: Token refresh should not block UI rendering or cause noticeable delays
  • Reliability: Should handle network failures gracefully and retry token refresh logic
  • Scalability: Design should support high concurrent request volumes without token refresh contention
  • Security: Refresh tokens must not be exposed in logs or client-side errors
  • Accessibility: Token refresh should not interrupt keyboard navigation or screen reader users

Constraints and assumptions

  • Authentication is based on JWT with refresh token flow
  • Refresh tokens are stored securely (e.g., HTTP-only cookies or secure storage)
  • API responses include standard HTTP status codes for token expiration (401/403)
  • Application has access to a centralized authentication service or API client
  • No cross-origin token refresh is required (same domain only)
  • Token refresh logic should not introduce significant bundle size overhead

Out of scope

  • Refresh token rotation or refresh token revocation
  • Token encryption or secure storage outside of browser (e.g., encrypted local storage)
  • Token refresh for third-party services or external APIs
  • Integration with authentication providers like Auth0, Firebase, or Okta (unless explicitly required)
  • Refresh token lifecycle management beyond basic expiration handling
  • Token refresh for server-side rendered pages (SSR) or static site generation (SSG)
  • Token refresh in service workers or background processes

2. Component Architecture

graph TD
    A[AuthManager] --> B[TokenRefreshService]
    A --> C[AuthProvider]
    A --> D[AuthInterceptor]
    B --> E[TokenService]
    C --> F[UserContext]
    D --> G[APIHttpClient]
    G --> H[AuthErrorBoundary]

Component Tree Structure

The authentication system consists of a hierarchical component structure:

  • AuthManager - Root container managing authentication lifecycle
  • TokenRefreshService - Background token refresh logic
  • AuthProvider - Context provider for auth state
  • AuthInterceptor - HTTP client interceptor for token handling
  • TokenService - Core token operations and storage
  • UserContext - Authentication state consumer
  • APIHttpClient - HTTP client with interceptors
  • AuthErrorBoundary - Error handling for auth-related failures

Component Classification

Smart Components (Container):

  • AuthManager - Orchestrates authentication flow, manages token refresh lifecycle
  • AuthProvider - Provides authentication context to child components
  • TokenRefreshService - Implements background refresh logic with retry mechanisms

Dumb Components (Presentational):

  • UserContext - Consumes authentication state from context
  • AuthErrorBoundary - Handles auth-related error states

TypeScript Props Contracts

// AuthManager props
interface AuthManagerProps {
  children: React.ReactNode;
  onAuthSuccess?: (user: User) => void;
  onAuthFailure?: () => void;
  refreshThresholdMs?: number;
  maxRefreshAttempts?: number;
}

// TokenRefreshService props
interface TokenRefreshServiceProps {
  onTokenRefreshed: (newToken: string) => void;
  onError: (error: Error) => void;
  refreshThresholdMs: number;
  maxRefreshAttempts: number;
}

// AuthInterceptor props
interface AuthInterceptorProps {
  httpClient: HttpClient;
  onTokenRefreshed: (newToken: string) => void;
  onAuthFailure: () => void;
}

UI State Machine

stateDiagram-v2
    [*] --> Idle
    Idle --> CheckingTokenExpiry
    CheckingTokenExpiry --> RefreshingToken
    CheckingTokenExpiry --> Idle
    RefreshingToken --> TokenRefreshSuccess
    RefreshingToken --> TokenRefreshFailed
    TokenRefreshSuccess --> Idle
    TokenRefreshFailed --> RetryTokenRefresh
    RetryTokenRefresh --> RefreshingToken
    RetryTokenRefresh --> AuthFailure
    AuthFailure --> Idle

Component Responsibilities

AuthManager:

  • Initializes authentication flow
  • Manages token refresh lifecycle
  • Handles auth state transitions
  • Provides error boundaries for authentication failures

TokenRefreshService:

  • Monitors token expiry timing
  • Triggers refresh before expiration
  • Implements retry logic for failed refreshes
  • Manages concurrent refresh requests

AuthProvider:

  • Provides authentication context to components
  • Handles user session state updates
  • Manages token storage and retrieval

AuthInterceptor:

  • Intercepts HTTP requests to inject auth headers
  • Handles 401 responses for token refresh
  • Manages request queuing during refresh operations

Reusability Decisions

The authentication system is designed to be reusable across different application contexts:

  • Token refresh logic is abstracted into a service
  • HTTP interceptor can be applied to any client instance
  • Context provider supports different user models
  • Error handling follows standardized patterns

Component Lifecycle Considerations

  1. Mounting: AuthManager initializes token refresh timers and sets up interceptors
  2. Updating: TokenRefreshService monitors token expiry and triggers refreshes
  3. Unmounting: Cleanup of timers, event listeners, and ongoing requests
  4. Error Recovery: Graceful handling of refresh failures with retry logic

The component lifecycle ensures proper cleanup to prevent memory leaks and maintain consistent authentication state across the application.

3. State Management Design

Local state vs. global state decisions

For automatic token refresh, we need to manage authentication state across the entire application. The token refresh logic should be handled globally since:

  • Multiple components may need to access and potentially refresh tokens
  • Token state is application-wide (not component-specific)
  • We need to coordinate token refresh attempts to avoid multiple concurrent requests
  • Error handling and retry logic should be centralized

State management approach

Zustand is selected as the state management solution because:

  1. Simplicity: Minimal boilerplate and easy to understand for team adoption
  2. TypeScript native: Excellent type support with no additional configuration needed
  3. No provider wrapping: Avoids unnecessary component tree bloat
  4. Middleware support: Can easily add logging, persistence, or other middleware
  5. Lightweight: Bundle size impact is minimal compared to Redux Toolkit

State shape and normalization strategy

interface AuthState {
  accessToken: string | null;
  refreshToken: string | null;
  isAuthenticated: boolean;
  tokenExpiry: number | null;
  refreshStatus: 'idle' | 'pending' | 'success' | 'error';
  refreshError: string | null;
}

Server state vs. client state separation

  • Client state: Authentication tokens, refresh status, user session metadata
  • Server state: User profile data, permissions, API responses (managed by React Query)

Cache invalidation strategy

When tokens are refreshed:

  1. Invalidate all cached API requests that used the old token
  2. Update all auth-related React Query cache entries
  3. Clear any expired token-based cache keys

Optimistic updates design

For refresh operations, we implement:

  1. Optimistic token replacement: Update local state immediately when refresh starts
  2. Fallback handling: If refresh fails, revert to previous token state with error notification
  3. Concurrent request handling: Prevent multiple simultaneous refresh attempts

Critical-path hook implementation

import { create } from 'zustand';
import { persist } from 'zustand/middleware';

interface AuthStore {
  accessToken: string | null;
  refreshToken: string | null;
  isAuthenticated: boolean;
  tokenExpiry: number | null;
  refreshStatus: 'idle' | 'pending' | 'success' | 'error';
  refreshError: string | null;
  
  // Actions
  setTokens: (accessToken: string, refreshToken: string) => void;
  clearTokens: () => void;
  setRefreshStatus: (status: 'idle' | 'pending' | 'success' | 'error') => void;
  setRefreshError: (error: string | null) => void;
  refreshAccessToken: () => Promise<boolean>;
}

const useAuthStore = create<AuthStore>()(
  persist(
    (set, get) => ({
      accessToken: null,
      refreshToken: null,
      isAuthenticated: false,
      tokenExpiry: null,
      refreshStatus: 'idle',
      refreshError: null,

      setTokens: (accessToken, refreshToken) => {
        const exp = Math.floor(Date.now() / 1000) + 3600; // 1 hour expiry
        set({
          accessToken,
          refreshToken,
          isAuthenticated: true,
          tokenExpiry: exp
        });
      },

      clearTokens: () => {
        set({
          accessToken: null,
          refreshToken: null,
          isAuthenticated: false,
          tokenExpiry: null,
          refreshStatus: 'idle',
          refreshError: null
        });
      },

      setRefreshStatus: (status) => {
        set({ refreshStatus: status });
      },

      setRefreshError: (error) => {
        set({ refreshError: error });
      },

      refreshAccessToken: async () => {
        const { refreshToken, refreshStatus } = get();
        
        // Prevent concurrent refresh attempts
        if (refreshStatus === 'pending' || !refreshToken) {
          return false;
        }

        set({ refreshStatus: 'pending' });
        
        try {
          const response = await fetch('/api/auth/refresh', {
            method: 'POST',
            headers: {
              'Content-Type': 'application/json',
            },
            body: JSON.stringify({ refreshToken }),
          });

          if (!response.ok) {
            const error = await response.json();
            throw new Error(error.message || 'Token refresh failed');
          }

          const { accessToken } = await response.json();
          
          set({
            accessToken,
            refreshStatus: 'success',
            refreshError: null
          });

          return true;
        } catch (error) {
          set({
            refreshStatus: 'error',
            refreshError: error instanceof Error ? error.message : 'Unknown refresh error'
          });
          
          // Clear tokens on refresh failure
          get().clearTokens();
          return false;
        }
      },
    }),
    {
      name: 'auth-storage',
      partialize: (state) => ({
        accessToken: state.accessToken,
        refreshToken: state.refreshToken,
      }),
    }
  )
);

// Custom hook for token refresh with automatic retry logic
export const useTokenRefresh = () => {
  const { 
    accessToken, 
    refreshToken, 
    isAuthenticated,
    refreshStatus,
    refreshAccessToken,
    setRefreshError
  } = useAuthStore();

  const handleTokenRefresh = async (): Promise<boolean> => {
    if (refreshStatus === 'pending') {
      // Wait for ongoing refresh to complete
      return new Promise((resolve) => {
        const interval = setInterval(() => {
          if (useAuthStore.getState().refreshStatus !== 'pending') {
            clearInterval(interval);
            resolve(useAuthStore.getState().isAuthenticated);
          }
        }, 100);
      });
    }

    if (refreshStatus === 'error') {
      // Clear error state and retry
      setRefreshError(null);
    }

    return refreshAccessToken();
  };

  return {
    accessToken,
    isAuthenticated,
    refreshStatus,
    handleTokenRefresh
  };
};

// Token interceptor for API client
export const createAuthInterceptor = () => {
  const store = useAuthStore.getState();
  
  return async (request: Request): Promise<Request> => {
    const { accessToken, refreshToken } = store;
    
    // If we have a valid token, add it to the request
    if (accessToken) {
      return new Request(request, {
        headers: {
          ...request.headers,
          Authorization: `Bearer ${accessToken}`,
        },
      });
    }

    // If we don't have a token but have refresh token, attempt refresh
    if (refreshToken && !store.refreshStatus) {
      const success = await store.refreshAccessToken();
      if (success) {
        return new Request(request, {
          headers: {
            ...request.headers,
            Authorization: `Bearer ${store.accessToken}`,
          },
        });
      }
    }

    return request;
  };
};

The hook returns:

  • accessToken: Current valid access token
  • isAuthenticated: Authentication status flag
  • refreshStatus: Current refresh operation state
  • handleTokenRefresh: Async function to trigger token refresh with proper concurrency handling

Key state transitions:

  1. idle → pending → success or error
  2. Error state resets to idle on retry
  3. Concurrent refresh attempts are queued and handled sequentially

Side-effect handlers:

  • Token refresh API calls
  • Cache invalidation on successful refresh
  • Error state management and user notifications

4. Routing & Navigation

Route Structure and Hierarchy

graph TD
    A[Root] --> B[Public Routes]
    A --> C[Protected Routes]
    B --> B1[Login]
    B --> B2[Register]
    B --> B3[Forgot Password]
    C --> C1[Dashboard]
    C --> C2[Profile]
    C --> C3[Settings]
    C --> C4[Admin]
    C1 --> C11[Analytics]
    C1 --> C12[Reports]

The application will follow a protected route structure with:

  • Public routes: Login, Register, Forgot Password (no authentication required)
  • Protected routes: Dashboard, Profile, Settings, Admin (require valid auth token)
  • Nested routes under protected areas for feature organization

Code Splitting Strategy

Route-based code splitting with React.lazy and Suspense:

// routes/index.tsx
import { lazy, Suspense } from 'react';
import { RouteObject } from 'react-router-dom';

const Login = lazy(() => import('../features/auth/Login'));
const Dashboard = lazy(() => import('../features/dashboard/Dashboard'));
const Profile = lazy(() => import('../features/profile/Profile'));

export const routes: RouteObject[] = [
  {
    path: '/login',
    element: (
      <Suspense fallback={<div>Loading...</div>}>
        <Login />
      </Suspense>
    ),
  },
  {
    path: '/dashboard',
    element: (
      <ProtectedRoute>
        <Suspense fallback={<div>Loading...</div>}>
          <Dashboard />
        </Suspense>
      </ProtectedRoute>
    ),
  },
  {
    path: '/profile',
    element: (
      <ProtectedRoute>
        <Suspense fallback={<div>Loading...</div>}>
          <Profile />
        </Suspense>
      </ProtectedRoute>
    ),
  },
];

Justification:

  • Route-based splitting reduces initial bundle size
  • Protected routes can be lazy-loaded after authentication check
  • Public pages load immediately for better perceived performance
// components/ProtectedRoute.tsx
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuthStore } from '../stores/auth';

export const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
  const [isAuthenticated, setIsAuthenticated] = useState<boolean | null>(null);
  const navigate = useNavigate();
  const { token, refreshToken, isRefreshing } = useAuthStore();

  useEffect(() => {
    const checkAuth = async () => {
      if (!token && !refreshToken) {
        setIsAuthenticated(false);
        return;
      }

      // If token is expired but refresh is in progress, wait
      if (isRefreshing) {
        // Optionally show loading spinner or redirect to login
        return;
      }

      try {
        const isValid = await validateToken(token);
        if (isValid) {
          setIsAuthenticated(true);
        } else {
          // Token expired, attempt refresh
          const refreshed = await refreshAuth(refreshToken);
          if (refreshed) {
            setIsAuthenticated(true);
          } else {
            setIsAuthenticated(false);
          }
        }
      } catch (error) {
        setIsAuthenticated(false);
      }
    };

    checkAuth();
  }, [token, refreshToken, isRefreshing]);

  if (isAuthenticated === null) {
    return <div>Loading...</div>;
  }

  if (!isAuthenticated) {
    navigate('/login');
    return null;
  }

  return <>{children}</>;
};

Deep Linking Support and URL Parameter Design

URL structure will follow RESTful conventions:

  • /dashboard/analytics?period=7d&filter=active
  • /profile/settings?tab=notifications

Query parameters will be normalized and validated:

// utils/url.ts
export const parseUrlParams = (search: string) => {
  const params = new URLSearchParams(search);
  return Object.fromEntries(params.entries());
};

export const buildUrl = (base: string, params: Record<string, string>) => {
  const url = new URL(base, window.location.origin);
  Object.entries(params).forEach(([key, value]) => {
    url.searchParams.append(key, value);
  });
  return url.toString();
};

Browser History Management and Scroll Restoration

// components/ScrollRestoration.tsx
import { useEffect } from 'react';
import { useLocation } from 'react-router-dom';

export const ScrollRestoration = () => {
  const location = useLocation();

  useEffect(() => {
    // Restore scroll position when navigating back
    const handlePopState = () => {
      window.scrollTo(0, 0);
    };

    window.addEventListener('popstate', handlePopState);
    
    // Scroll to top on route change
    window.scrollTo(0, 0);

    return () => {
      window.removeEventListener('popstate', handlePopState);
    };
  }, [location]);

  return null;
};

The application will use React Router's built-in scroll restoration with custom handling for:

  • Page-level scroll position preservation
  • Deep link navigation consistency
  • Smooth scrolling behavior for anchor links

5. API Integration Layer

API Client Design Pattern

We'll implement a centralized Axios instance with request/response interceptors to handle automatic token refresh and consistent error handling across all API calls.

// api/client.ts
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios';

const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3000/api';

const apiClient: AxiosInstance = axios.create({
  baseURL: API_BASE_URL,
  timeout: 10000,
  withCredentials: true, // Required for cookies
});

export default apiClient;

Request/Response Interceptors

The interceptor system handles automatic token refresh when a 401 Unauthorized response is received, ensuring that the user's session remains valid without interrupting their workflow.

// api/interceptors.ts
import axios, { AxiosError, AxiosRequestConfig, AxiosResponse } from 'axios';
import apiClient from './client';
import { getAccessToken, setAccessToken } from '@/utils/authStorage';
import { refreshToken } from '@/services/authService';

let isRefreshing = false;
let failedQueue: Array<{ resolve: Function; reject: Function }> = [];

const processQueue = (error: AxiosError | null, token: string | null = null) => {
  failedQueue.forEach(prom => {
    if (error) {
      prom.reject(error);
    } else {
      prom.resolve(token);
    }
  });
  
  failedQueue = [];
};

apiClient.interceptors.request.use(
  (config: AxiosRequestConfig) => {
    const token = getAccessToken();
    if (token && config.headers) {
      config.headers.Authorization = `Bearer ${token}`;
    }
    return config;
  },
  (error) => {
    return Promise.reject(error);
  }
);

apiClient.interceptors.response.use(
  (response: AxiosResponse) => response,
  async (error: AxiosError) => {
    const originalRequest = error.config;
    
    if (error.response?.status === 401 && !originalRequest._retry) {
      if (isRefreshing) {
        // If we're already refreshing, queue this request
        return new Promise((resolve, reject) => {
          failedQueue.push({ resolve, reject });
        }).then(token => {
          originalRequest._retry = true;
          originalRequest.headers.Authorization = `Bearer ${token}`;
          return apiClient(originalRequest);
        }).catch(err => {
          return Promise.reject(err);
        });
      }

      originalRequest._retry = true;
      isRefreshing = true;

      try {
        const newToken = await refreshToken();
        setAccessToken(newToken);
        
        processQueue(null, newToken);
        
        originalRequest.headers.Authorization = `Bearer ${newToken}`;
        return apiClient(originalRequest);
      } catch (refreshError) {
        processQueue(refreshError, null);
        // Redirect to login page or trigger logout
        window.location.href = '/login';
        return Promise.reject(refreshError);
      } finally {
        isRefreshing = false;
      }
    }

    return Promise.reject(error);
  }
);

Error Handling and Retry Strategy

Comprehensive error handling with retry logic for transient network issues, and proper user feedback mechanisms.

// api/errorHandler.ts
import { AxiosError } from 'axios';

export class ApiError extends Error {
  constructor(
    public status: number,
    public message: string,
    public code?: string
  ) {
    super(message);
    this.name = 'ApiError';
  }
}

export const handleApiError = (error: AxiosError): ApiError => {
  if (error.response) {
    const { status, data } = error.response;
    
    // Handle specific HTTP status codes
    switch (status) {
      case 401:
        return new ApiError(401, 'Unauthorized - Please log in again', 'UNAUTHORIZED');
      case 403:
        return new ApiError(403, 'Forbidden - You do not have permission', 'FORBIDDEN');
      case 404:
        return new ApiError(404, 'Resource not found', 'NOT_FOUND');
      case 429:
        return new ApiError(429, 'Rate limit exceeded', 'RATE_LIMITED');
      case 500:
        return new ApiError(500, 'Internal server error', 'INTERNAL_ERROR');
      default:
        return new ApiError(status, data?.message || 'API request failed', 'UNKNOWN_ERROR');
    }
  }

  if (error.request) {
    return new ApiError(0, 'Network error - Please check your connection', 'NETWORK_ERROR');
  }

  return new ApiError(0, error.message || 'Unknown API error', 'UNKNOWN_ERROR');
};

// Retry configuration
export const retryConfig = {
  retries: 3,
  retryDelay: (retryCount: number) => Math.min(1000 * 2 ** retryCount, 10000),
  retryCondition: (error: AxiosError) => {
    return (
      error.code === 'ECONNABORTED' ||
      error.message.includes('timeout') ||
      [500, 502, 503, 504].includes(error.response?.status || 0)
    );
  }
};

Loading, Error, and Empty States Design

Consistent state management across all API-consuming components with proper loading indicators and error feedback.

// hooks/useApi.ts
import { useState, useEffect } from 'react';
import { AxiosError } from 'axios';
import { ApiError, handleApiError } from '@/api/errorHandler';

type LoadingState = 'idle' | 'loading' | 'success' | 'error';

interface ApiResult<T> {
  data: T | null;
  loading: LoadingState;
  error: ApiError | null;
  refetch: () => void;
}

export const useApi = <T>(apiCall: () => Promise<T>): ApiResult<T> => {
  const [data, setData] = useState<T | null>(null);
  const [loading, setLoading] = useState<LoadingState>('idle');
  const [error, setError] = useState<ApiError | null>(null);

  const refetch = async () => {
    setLoading('loading');
    setError(null);
    
    try {
      const result = await apiCall();
      setData(result);
      setLoading('success');
    } catch (err) {
      const apiError = handleApiError(err as AxiosError);
      setError(apiError);
      setLoading('error');
    }
  };

  useEffect(() => {
    refetch();
  }, []);

  return { data, loading, error, refetch };
};

Data Fetching Strategy

For a modern Next.js application, we'll use a hybrid approach combining server-side rendering for initial loads and client-side fetching for dynamic updates:

  • SSR Routes: Use getServerSideProps for authenticated pages that require initial data
  • CSR Routes: Use React Query for client-side data fetching with automatic caching and refetching
  • ISR Routes: For content that needs to be updated periodically but doesn't require real-time updates
// pages/dashboard.tsx
import { GetServerSideProps } from 'next';
import { dehydrate, QueryClient } from '@tanstack/react-query';

export const getServerSideProps: GetServerSideProps = async (context) => {
  const queryClient = new QueryClient();
  
  // Fetch initial data on server
  await queryClient.prefetchQuery(['userProfile'], () => fetchUserProfile());
  
  return {
    props: {
      dehydratedState: dehydrate(queryClient),
    },
  };
};

// Component using React Query
import { useQuery } from '@tanstack/react-query';

const Dashboard = () => {
  const { data, isLoading, error } = useQuery(['userProfile'], fetchUserProfile);
  
  if (isLoading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;
  
  return (
    <div>
      <h1>Welcome, {data?.name}</h1>
      {/* Dashboard content */}
    </div>
  );
};

API Response Transformation Layer

Consistent data transformation from raw API responses to domain models for better type safety and maintainability.

// api/transformers.ts
export interface UserResponse {
  id: string;
  name: string;
  email: string;
  createdAt: string;
}

export interface User {
  id: string;
  name: string;
  email: string;
  createdAt: Date;
}

export const transformUserResponse = (response: UserResponse): User => ({
  id: response.id,
  name: response.name,
  email: response.email,
  createdAt: new Date(response.createdAt),
});

Sequence Diagram for Primary Data Flows

sequenceDiagram
    participant Client
    participant AuthInterceptor
    participant AuthService
    participant TokenService
    
    Client->>AuthInterceptor: Make API request with token
    AuthInterceptor->>AuthInterceptor: Check token validity
    AuthInterceptor->>AuthService: Token invalid, request refresh
    AuthService->>TokenService: Request new token
    TokenService->>AuthService: Return new access token
    AuthService->>AuthInterceptor: Return new token to client
    AuthInterceptor->>Client: Retry original request with new token
    Client->>Client: Process successful response
    
    Note over AuthService,TokenService: Token refresh happens transparently

Authentication Service Implementation

// services/authService.ts
import apiClient from '@/api/client';

export const refreshToken = async (): Promise<string> => {
  try {
    const response = await apiClient.post('/auth/refresh');
    return response.data.accessToken;
  } catch (error) {
    throw new Error('Failed to refresh token');
  }
};

export const logout = async (): Promise<void> => {
  try {
    await apiClient.post('/auth/logout');
  } catch (error) {
    console.error('Logout failed', error);
  } finally {
    // Clear local storage and redirect
    localStorage.removeItem('accessToken');
    window.location.href = '/login';
  }
};

6. Performance Design

Core Web Vitals Targets

  • Largest Contentful Paint (LCP): <2.5s for 95% of users
  • Interaction to Next Paint (INP): <200ms for 90% of interactions
  • Cumulative Layout Shift (CLS): <0.1 for 95% of users
  • First Contentful Paint (FCP): <1.8s for 95% of users

Bundle Size Budget per Route

  • Home/Overview: <200KB (including all dependencies)
  • Auth Flow: <150KB (token refresh logic + auth components)
  • Profile/Settings: <100KB (minimal dependencies)
  • Data-heavy Views: <300KB (virtualized lists, charts)

Critical Rendering Path Optimization

  • Critical CSS: Inline essential styles for above-the-fold content
  • Resource Hints: Preload critical fonts and assets (<link rel="preload">)
  • Code Splitting: Route-based chunking to reduce initial load
  • Lazy Loading: Non-critical components loaded on demand

Image Optimization Strategy

  • Formats: WebP for modern browsers, JPEG fallback for older
  • Lazy Loading: loading="lazy" attribute on all images
  • Responsive Images: srcset with multiple sizes for different viewports
  • Image Compression: Server-side optimization with WebP conversion

Font Loading Strategy

  • Font Display: font-display: swap to prevent FOIT
  • Preload Critical Fonts: <link rel="preload" as="font"> for primary font families
  • Font Subsets: Load only necessary character sets (Latin, Cyrillic, etc.)
  • Fallback Fonts: Use system fonts as fallback to prevent layout shifts

Memoization Strategy

// ❌ Avoid - unnecessary re-renders
const UserCard = ({ user }: { user: User }) => {
  const formattedName = formatUserName(user.name);
  return <div>{formattedName}</div>;
};

// ✅ Do - memoize expensive operations
const UserCard = React.memo(({ user }: { user: User }) => {
  const formattedName = useMemo(() => formatUserName(user.name), [user.name]);
  return <div>{formattedName}</div>;
});

// ✅ Do - memoize callback handlers
const UserList = ({ users }: { users: User[] }) => {
  const handleUserClick = useCallback((userId: string) => {
    // navigation logic
  }, []);
  
  return (
    <ul>
      {users.map(user => (
        <li key={user.id}>
          <UserCard user={user} onClick={handleUserClick} />
        </li>
      ))}
    </ul>
  );
};

Virtualization for Large Lists

// For large lists, use react-window or react-virtual
import { FixedSizeList as List } from 'react-window';

const VirtualizedUserList = ({ users }: { users: User[] }) => {
  const Row = ({ index, style }: { index: number; style: React.CSSProperties }) => (
    <div style={style}>
      <UserCard user={users[index]} />
    </div>
  );

  return (
    <List
      height={600}
      itemCount={users.length}
      itemSize={80}
      width="100%"
    >
      {Row}
    </List>
  );
};

Prefetching and Preloading Strategy

  • Token Refresh: Preemptively refresh token 5 minutes before expiration
  • Critical Routes: Prefetch data for next likely route (e.g., dashboard after login)
  • Preload Dependencies: Load heavy components only when user is likely to navigate there
  • Background Fetching: Fetch non-critical data in background when user is idle
// Token refresh prefetching strategy
const tokenRefreshService = {
  // Schedule refresh 5 minutes before expiration
  scheduleTokenRefresh: (tokenExpiry: Date) => {
    const refreshTime = new Date(tokenExpiry).getTime() - 5 * 60 * 1000;
    const timeUntilRefresh = Math.max(0, refreshTime - Date.now());
    
    setTimeout(() => {
      // Refresh token in background
      refreshToken();
    }, timeUntilRefresh);
  }
};

7. Error Handling & Resilience

Error Boundary Placement Strategy

graph TD
    A[App Root] --> B[ErrorBoundary]
    B --> C[MainLayout]
    C --> D[DashboardPage]
    C --> E[UserSettingsPage]
    D --> F[DashboardContent]
    D --> G[UserList]
    G --> H[UserCard]
    G --> I[UserCardSkeleton]
    F --> J[AnalyticsWidget]
    F --> K[RecentActivity]

Error boundaries are strategically placed at the component tree levels where graceful degradation is most important:

  • App-level boundary: Catches unhandled errors in the entire application
  • Page-level boundaries: Wrap individual pages to prevent cascading failures
  • Component-level boundaries: Wrap critical sub-components like user cards or analytics widgets

Global Error Handler Design

// src/core/error/GlobalErrorHandler.tsx
import React from 'react';

interface GlobalErrorState {
  hasError: boolean;
  error: Error | null;
  errorInfo: React.ErrorInfo | null;
}

class GlobalErrorHandler extends React.Component<{}, GlobalErrorState> {
  constructor(props: {}) {
    super(props);
    this.state = { hasError: false, error: null, errorInfo: null };
  }

  static getDerivedStateFromError(error: Error): GlobalErrorState {
    return { hasError: true, error, errorInfo: null };
  }

  componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void {
    this.setState({ errorInfo });
    // Log to monitoring service (Sentry, etc.)
    console.error('Global error caught:', error, errorInfo);
  }

  render(): React.ReactNode {
    if (this.state.hasState) {
      return (
        <div className="error-container">
          <h2>Something went wrong.</h2>
          <details style={{ whiteSpace: 'pre-wrap' }}>
            {this.state.error && this.state.error.toString()}
            <br />
            {this.state.errorInfo?.componentStack}
          </details>
        </div>
      );
    }

    return this.props.children;
  }
}

export default GlobalErrorHandler;

Offline / Network Failure Handling

// src/core/network/NetworkStatusManager.ts
import { useEffect, useState } from 'react';

export const useNetworkStatus = () => {
  const [isOnline, setIsOnline] = useState<boolean>(navigator.onLine);
  const [lastError, setLastError] = useState<Error | null>(null);

  useEffect(() => {
    const handleOnline = () => setIsOnline(true);
    const handleOffline = () => setIsOnline(false);

    window.addEventListener('online', handleOnline);
    window.addEventListener('offline', handleOffline);

    return () => {
      window.removeEventListener('online', handleOnline);
      window.removeEventListener('offline', handleOffline);
    };
  }, []);

  const handleNetworkError = (error: Error) => {
    setLastError(error);
    // Trigger retry logic or fallback UI
  };

  return { isOnline, lastError, handleNetworkError };
};

Graceful Degradation Patterns

// src/components/UserList/UserList.tsx
import React, { useEffect, useState } from 'react';
import { useNetworkStatus } from '../../core/network/NetworkStatusManager';

interface User {
  id: string;
  name: string;
  email: string;
}

const UserList = () => {
  const [users, setUsers] = useState<User[]>([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);
  const { isOnline } = useNetworkStatus();

  useEffect(() => {
    const fetchUsers = async () => {
      try {
        setLoading(true);
        const response = await fetch('/api/users');
        
        if (!response.ok) {
          throw new Error(`HTTP error! status: ${response.status}`);
        }
        
        const userData = await response.json();
        setUsers(userData);
      } catch (err) {
        setError(err instanceof Error ? err.message : 'Failed to fetch users');
        // Fallback to cached data if available
        if (localStorage.getItem('cachedUsers')) {
          setUsers(JSON.parse(localStorage.getItem('cachedUsers') || '[]'));
        }
      } finally {
        setLoading(false);
      }
    };

    fetchUsers();
  }, []);

  if (loading) {
    return <UserListSkeleton />;
  }

  if (error && !isOnline) {
    return (
      <div className="offline-fallback">
        <p>Offline mode - showing cached data</p>
        <UserList items={users} />
      </div>
    );
  }

  if (error && isOnline) {
    return (
      <div className="error-fallback">
        <p>Failed to load users. Please try again later.</p>
        <button onClick={() => window.location.reload()}>
          Retry
        </button>
      </div>
    );
  }

  return <UserList items={users} />;
};

export default UserList;

User Feedback on Errors

// src/core/toast/ToastManager.tsx
import React from 'react';
import { ToastContainer, toast } from 'react-toastify';

interface ToastOptions {
  autoClose?: number;
  position?: 'top-right' | 'top-center' | 'top-left' | 'bottom-right' | 'bottom-center' | 'bottom-left';
  hideProgressBar?: boolean;
  closeOnClick?: boolean;
  pauseOnHover?: boolean;
}

export const ToastManager = () => (
  <ToastContainer
    position="top-right"
    autoClose={5000}
    hideProgressBar={false}
    newestOnTop
    closeOnClick
    rtl={false}
    pauseOnHover
  />
);

export const showTokenRefreshError = (message: string) => {
  toast.error(message, {
    position: "top-right",
    autoClose: 5000,
    hideProgressBar: false,
    closeOnClick: true,
    pauseOnHover: true,
  });
};

export const showNetworkError = (message: string) => {
  toast.error(message, {
    position: "top-center",
    autoClose: 3000,
    hideProgressBar: true,
    closeOnClick: false,
    pauseOnHover: false,
  });
};

export const showOfflineWarning = () => {
  toast.warning("You're offline. Some features may be limited.", {
    position: "bottom-center",
    autoClose: 4000,
    hideProgressBar: true,
    closeOnClick: false,
    pauseOnHover: false,
  });
};

Retry Strategy Implementation

// src/core/api/RetryableRequest.ts
interface RetryConfig {
  maxRetries?: number;
  retryDelay?: number;
  backoffMultiplier?: number;
}

const defaultRetryConfig: RetryConfig = {
  maxRetries: 3,
  retryDelay: 1000,
  backoffMultiplier: 2,
};

export const withRetry = async <T>(
  requestFn: () => Promise<T>,
  config: RetryConfig = defaultRetryConfig
): Promise<T> => {
  const { maxRetries, retryDelay, backoffMultiplier } = config;
  let lastError: Error | null = null;

  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      return await requestFn();
    } catch (error) {
      lastError = error as Error;
      
      if (attempt === maxRetries) {
        throw lastError;
      }

      // Exponential backoff
      const delay = retryDelay * Math.pow(backoffMultiplier, attempt);
      await new Promise(resolve => setTimeout(resolve, delay));
    }
  }

  throw lastError!;
};

Token Refresh Error Recovery

// src/core/auth/TokenRefreshManager.ts
import { useState, useEffect } from 'react';
import { withRetry } from '../api/RetryableRequest';

interface TokenRefreshState {
  isRefreshing: boolean;
  lastTokenRefresh: Date | null;
  refreshError: Error | null;
}

export const useTokenRefresh = () => {
  const [state, setState] = useState<TokenRefreshState>({
    isRefreshing: false,
    lastTokenRefresh: null,
    refreshError: null,
  });

  const refreshAccessToken = async (): Promise<string> => {
    try {
      setState(prev => ({ ...prev, isRefreshing: true }));
      
      const response = await withRetry(() => 
        fetch('/api/auth/refresh', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ refreshToken: getStoredRefreshToken() })
        })
      );

      if (!response.ok) {
        throw new Error(`Token refresh failed: ${response.status}`);
      }

      const { accessToken } = await response.json();
      storeAccessToken(accessToken);
      
      setState(prev => ({
        ...prev,
        isRefreshing: false,
        lastTokenRefresh: new Date(),
        refreshError: null
      }));

      return accessToken;
    } catch (error) {
      setState(prev => ({
        ...prev,
        isRefreshing: false,
        refreshError: error as Error
      }));
      
      throw error;
    }
  };

  return {
    ...state,
    refreshAccessToken
  };
};

8. Testing Strategy

Unit Testing Approach

Component-level testing (70% coverage target):

  • Test all interactive components with various state transitions
  • Mock external dependencies (API calls, browser APIs)
  • Verify proper rendering for all state combinations (loading, success, error)
  • Test edge cases: empty data, malformed responses, invalid inputs

Utility function testing (90% coverage target):

  • Token validation logic with various JWT formats
  • Refresh token expiration handling
  • Error state normalization functions
  • HTTP status code mapping to user-facing messages

Custom hook testing (85% coverage target):

  • Test token refresh side effects and state transitions
  • Verify proper cleanup of timers and subscriptions
  • Mock API responses to test error recovery flows
  • Test concurrent refresh attempts and race conditions

Integration Testing Strategy

API integration tests (60% coverage):

  • End-to-end flow from token expiration to refresh success
  • Test concurrent requests during refresh window (401 handling)
  • Verify proper token propagation to downstream API calls
  • Test refresh failure scenarios with fallback logic

State synchronization tests (50% coverage):

  • Ensure UI updates correctly when tokens refresh
  • Validate that pending requests are retried with new tokens
  • Test session persistence across page reloads (localStorage)
  • Verify token refresh doesn't break ongoing user interactions

E2E Testing Scope

Cypress-based testing (focus on critical flows):

  • Authentication flow: login → token refresh → logout
  • Concurrent request handling during refresh window
  • Session timeout and automatic re-login scenarios
  • User journey: token expiration mid-action → retry success

Test coverage priorities:

  1. Token refresh during active user session (high priority)
  2. Concurrent API calls during refresh window (medium priority)
  3. Token expiration edge cases (low priority but critical)

Visual Regression Testing

Screenshot testing strategy:

  • Capture UI states during token refresh transitions
  • Test loading spinners, error messages, and success states
  • Validate responsive behavior across device sizes
  • Monitor for layout shifts during refresh operations

Accessibility Testing Automation

axe-core integration (100% coverage):

  • Test keyboard navigation through token refresh states
  • Verify proper ARIA attributes on loading indicators
  • Ensure focus management during error recovery flows
  • Validate screen reader announcements for refresh status

Test Data Strategy

Test data fixtures:

  • Valid JWT tokens with various expiration times (immediate, 1min, 30min)
  • Invalid token formats and malformed payloads
  • API response mocks with different HTTP status codes (401, 403, 500)
  • User session states: authenticated, expired, pending refresh

Test data management:

  • Use deterministic fixtures for reproducible test runs
  • Separate real API credentials from test data
  • Implement test environment configuration overrides
  • Maintain token expiration timeline for time-based tests

9. Accessibility (A11y)

WCAG 2.1 AA compliance checklist for this feature

The automatic token refresh system must maintain full accessibility compliance across all user interactions and error states:

  • Keyboard Navigation: All token refresh flows must be fully operable via keyboard (Tab/Shift+Tab navigation, Enter/Space activation)
  • Focus Management: Proper focus restoration after token refresh operations
  • Screen Reader Support: ARIA labels and roles for token refresh indicators and error states
  • Contrast Ratios: Sufficient color contrast for token refresh status indicators (4.5:1 minimum)
  • Error Handling: Clear error messages with appropriate ARIA attributes for assistive technologies
  • Time Limits: No automatic token refresh that interferes with user control or time limits

Keyboard navigation support

All token refresh operations must be keyboard-accessible:

  • Users can navigate to token refresh indicators using Tab key
  • Refresh actions are triggerable with Enter or Space key
  • Focus is properly managed during refresh operations (no focus loss)
  • Keyboard users receive appropriate feedback when tokens are refreshed

Focus management strategy

Focus handling during token refresh:

stateDiagram-v2
    [*] --> Idle
    Idle --> Refreshing : User triggers refresh
    Refreshing --> Success : Token refreshed successfully
    Refreshing --> Error : Authentication failed
    Success --> Idle : Focus restored to original context
    Error --> Idle : Focus restored to error context

Focus management considerations:

  • Preserve user's original focus position when refresh begins
  • Restore focus to the most relevant element after refresh completes
  • Implement proper focus trapping for modal refresh states
  • Handle focus restoration in case of network errors or timeouts

ARIA roles and attributes plan

ARIA implementation for token refresh:

// TokenRefreshIndicator.tsx
interface TokenRefreshIndicatorProps {
  status: 'idle' | 'refreshing' | 'success' | 'error';
  message?: string;
  onRefresh?: () => void;
}

const TokenRefreshIndicator = ({
  status,
  message,
  onRefresh
}: TokenRefreshIndicatorProps) => {
  const ariaLabel = status === 'refreshing' 
    ? "Token refresh in progress"
    : status === 'success'
    ? "Token refreshed successfully"
    : status === 'error'
    ? "Token refresh failed"
    : "Token refresh available";

  return (
    <div 
      role="status"
      aria-live="polite"
      aria-label={ariaLabel}
      className={`token-refresh-indicator ${status}`}
    >
      {status === 'refreshing' && (
        <span aria-hidden="true">🔄</span>
      )}
      {message && (
        <span className="message">{message}</span>
      )}
      {status === 'error' && (
        <button 
          onClick={onRefresh}
          aria-label="Retry token refresh"
          className="retry-button"
        >
          Retry
        </button>
      )}
    </div>
  );
};

Screen reader compatibility

Screen reader considerations:

  • All token refresh status messages are announced via aria-live="polite"
  • Error messages include clear guidance for users to retry or contact support
  • Success notifications provide confirmation that the refresh was completed successfully
  • Visual indicators have appropriate text alternatives for screen reader users
  • Focus management ensures screen readers don't miss important state changes

Keyboard navigation and focus restoration patterns

// TokenRefreshManager.tsx - Focus management example
const useTokenRefreshFocus = () => {
  const [lastFocusedElement, setLastFocusedElement] = useState<HTMLElement | null>(null);
  
  const handleRefreshStart = () => {
    // Store the currently focused element
    setLastFocusedElement(document.activeElement as HTMLElement);
    
    // Announce refresh start to screen readers
    announceToScreenReader("Refreshing authentication token");
  };
  
  const handleRefreshComplete = () => {
    // Restore focus to the previously focused element
    if (lastFocusedElement && lastFocusedElement.focus) {
      lastFocusedElement.focus();
    }
  };
  
  return { handleRefreshStart, handleRefreshComplete };
};

ARIA state management for refresh operations

// TokenRefreshStatus.tsx - Complete accessibility implementation
const TokenRefreshStatus = () => {
  const [status, setStatus] = useState<'idle' | 'refreshing' | 'success' | 'error'>('idle');
  const [message, setMessage] = useState<string>('');
  
  const handleRefresh = useCallback(() => {
    setStatus('refreshing');
    setMessage('Refreshing authentication token...');
    
    // Announce to screen readers
    announceToScreenReader("Authentication token refresh initiated");
    
    // Refresh logic here...
  }, []);
  
  return (
    <div 
      role="status"
      aria-live="polite"
      aria-label={status === 'refreshing' 
        ? "Token refresh in progress" 
        : status === 'success'
        ? "Authentication token refreshed successfully"
        : "Token refresh failed - please try again"
      }
      className={`token-refresh-status ${status}`}
    >
      <div 
        role="progressbar"
        aria-valuemin={0}
        aria-valuemax={100}
        aria-valuenow={status === 'refreshing' ? 50 : 100}
        aria-label="Token refresh progress"
        className="progress-bar"
      >
        {status === 'refreshing' && (
          <div className="progress-fill" style={{ width: '50%' }} />
        )}
      </div>
      
      <span className="status-message">
        {message || 
          (status === 'success' ? "Token refreshed successfully" : 
           status === 'error' ? "Token refresh failed" : 
           "Ready to refresh token")}
      </span>
      
      {status === 'error' && (
        <button 
          onClick={handleRefresh}
          aria-label="Retry token refresh"
          className="retry-button"
        >
          Retry
        </button>
      )}
    </div>
  );
};

Focus trapping and restoration

// useFocusTrap.ts - Custom hook for focus management
const useFocusTrap = (isActive: boolean, containerRef: React.RefObject<HTMLElement>) => {
  useEffect(() => {
    if (!isActive) return;
    
    const handleFocusTrap = (event: KeyboardEvent) => {
      if (event.key === 'Tab') {
        const focusableElements = containerRef.current?.querySelectorAll(
          'button, input, select, textarea, [tabindex]:not([tabindex="-1"])'
        ) || [];
        
        if (focusableElements.length === 0) return;
        
        const firstElement = focusableElements[0] as HTMLElement;
        const lastElement = focusableElements[focusableElements.length - 1] as HTMLElement;
        
        if (event.shiftKey && document.activeElement === firstElement) {
          lastElement.focus();
          event.preventDefault();
        } else if (!event.shiftKey && document.activeElement === lastElement) {
          firstElement.focus();
          event.preventDefault();
        }
      }
    };
    
    document.addEventListener('keydown', handleFocusTrap);
    return () => document.removeEventListener('keydown', handleFocusTrap);
  }, [isActive]);
};

Screen reader announcement helper

// announceToScreenReader.ts - Utility for screen reader announcements
const announceToScreenReader = (message: string, priority: 'polite' | 'assertive' = 'polite') => {
  // Create a live region element if it doesn't exist
  let liveRegion = document.getElementById('live-region');
  if (!liveRegion) {
    liveRegion = document.createElement('div');
    liveRegion.id = 'live-region';
    liveRegion.setAttribute('aria-live', priority);
    liveRegion.setAttribute('aria-atomic', 'true');
    liveRegion.style.position = 'absolute';
    liveRegion.style.left = '-9999px';
    document.body.appendChild(liveRegion);
  }
  
  // Update the content
  liveRegion.textContent = message;
  
  // Clear after a short delay to ensure announcement
  setTimeout(() => {
    liveRegion.textContent = '';
  }, 1000);
};

Accessibility testing considerations

  • Test with screen readers (NVDA, JAWS, VoiceOver)
  • Verify keyboard navigation through all token refresh states
  • Validate ARIA attributes and roles in browser developer tools
  • Test focus management with keyboard-only navigation
  • Ensure proper announcement of status changes to screen reader users
  • Validate color contrast for all token refresh status indicators

10. Security Considerations

XSS Prevention Strategy

Content Sanitization: All user-generated content must be sanitized before rendering to prevent script injection. Implement a robust HTML sanitizer like DOMPurify for any content that comes from external sources.

// Example of XSS prevention in a component
import DOMPurify from 'dompurify';

const sanitizeAndRender = (htmlContent: string) => {
  const cleanHtml = DOMPurify.sanitize(htmlContent, {
    ALLOWED_TAGS: ['p', 'br', 'strong', 'em'],
    ALLOWED_ATTR: []
  });
  
  return <div dangerouslySetInnerHTML={{ __html: cleanHtml }} />;
};

Content Security Policy (CSP): Implement strict CSP headers to prevent inline script execution and restrict external resource loading.

CSRF Protection

Token-Based Protection: Implement anti-CSRF tokens for all state-changing requests:

  • Generate unique CSRF tokens per session
  • Include tokens in request headers or form data
  • Validate tokens server-side before processing requests
// Example CSRF token implementation
const useCSRFToken = () => {
  const [csrfToken, setCsrfToken] = useState<string | null>(null);
  
  useEffect(() => {
    // Fetch CSRF token on component mount
    fetch('/api/csrf-token')
      .then(res => res.json())
      .then(data => setCsrfToken(data.token));
  }, []);
  
  return csrfToken;
};

Content Security Policy (CSP) Headers

Header Configuration: Configure CSP headers to restrict:

  • Script sources (only allow trusted CDNs and local scripts)
  • Image sources (prevent loading from untrusted domains)
  • Frame sources (prevent framing attacks)
Content-Security-Policy: 
  default-src 'self'; 
  script-src 'self' 'unsafe-inline' https://trusted-cdn.com; 
  img-src 'self' data: https:; 
  frame-ancestors 'none';

Sensitive Data Handling in Frontend State

Token Storage Security:

  • Never store refresh tokens in localStorage or cookies with HTTPOnly flag disabled
  • Use secure, HttpOnly cookies for refresh tokens (if server-side storage is preferred)
  • Store access tokens in memory only (in-memory cache or React Context)
  • Implement automatic token clearing on logout and session timeout
// Secure token management pattern
class TokenManager {
  private accessToken: string | null = null;
  private refreshToken: string | null = null;
  
  setTokens(accessToken: string, refreshToken: string) {
    this.accessToken = accessToken;
    this.refreshToken = refreshToken;
    
    // Store in secure context, not localStorage
    // Use React Context or Redux for access token
  }
  
  clearTokens() {
    this.accessToken = null;
    this.refreshToken = null;
    
    // Clear from any in-memory stores
    // Remove from React Context or Redux state
  }
}

Input Sanitization Approach

Input Validation:

  • Validate all user inputs on both client and server
  • Implement strict input sanitization for:
    • Usernames (no special characters in certain contexts)
    • Email addresses (format validation)
    • Text content (length limits, character restrictions)
// Input sanitization example
const sanitizeInput = (input: string): string => {
  // Remove potentially dangerous characters
  return input
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#x27;');
};

const validateEmail = (email: string): boolean => {
  const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
  return emailRegex.test(email);
};

Additional Security Considerations

Secure Communication:

  • All API requests must use HTTPS only
  • Implement HSTS headers to enforce secure connections

Session Management:

  • Implement automatic session timeout after inactivity
  • Validate token expiration on every request
  • Implement proper logout handling to clear all session data

Rate Limiting:

  • Implement client-side request throttling to prevent abuse
  • Handle rate limit errors gracefully with user feedback

Token Refresh Security:

  • Refresh tokens should be short-lived and rotated
  • Implement token revocation on logout
  • Use secure random generation for token values

11. Internationalization (i18n)

Translation Key Structure and Naming Convention

Following BEM-style naming with feature-scoped keys to prevent collisions:

{
  "auth": {
    "login": {
      "title": "Login to Your Account",
      "email_label": "Email Address",
      "password_label": "Password",
      "submit_button": "Sign In"
    },
    "logout": {
      "confirm_message": "Are you sure you want to log out?",
      "yes_button": "Yes, Log Out",
      "no_button": "Cancel"
    }
  },
  "common": {
    "loading": "Loading...",
    "error": "An error occurred",
    "back_button": "Back"
  }
}

RTL Support Considerations

RTL support is implemented through CSS classes and direction properties:

  • CSS direction: rtl applied conditionally via theme context
  • Component-level RTL-aware layout components (Flex, Grid) that flip margins/padding
  • Text alignment properties (text-align: right for RTL)
  • Scroll behavior adjustments (right-aligned scrollbars in RTL mode)

Date, Number, Currency Formatting Strategy

Using Intl API for locale-aware formatting:

// Format date with timezone awareness
const formatDate = (date: Date, locale: string, options?: Intl.DateTimeFormatOptions) => {
  return new Intl.DateTimeFormat(locale, {
    year: 'numeric',
    month: 'long',
    day: 'numeric',
    timeZone: 'UTC',
    ...options
  }).format(date);
};

// Format currency with proper symbol placement
const formatCurrency = (amount: number, locale: string, currency: string) => {
  return new Intl.NumberFormat(locale, {
    style: 'currency',
    currency,
    minimumFractionDigits: 2
  }).format(amount);
};

// Format numbers with grouping separators
const formatNumber = (num: number, locale: string) => {
  return new Intl.NumberFormat(locale).format(num);
};

Locale Detection and Switching Strategy

Automatic locale detection using browser navigator:

const detectLocale = (): string => {
  const browserLang = navigator.language || 'en-US';
  const supportedLocales = ['en-US', 'fr-FR', 'es-ES', 'de-DE'];
  
  // Match exact locale or fallback to language code
  const matched = supportedLocales.find(locale => 
    locale === browserLang || 
    locale.startsWith(browserLang.split('-')[0])
  );
  
  return matched || 'en-US';
};

Manual locale switching via user preference:

const useLocaleSwitcher = () => {
  const [currentLocale, setCurrentLocale] = useState<string>('en-US');
  
  const switchLocale = useCallback((newLocale: string) => {
    setCurrentLocale(newLocale);
    // Update localStorage and apply to document
    localStorage.setItem('preferred-locale', newLocale);
    document.documentElement.lang = newLocale;
  }, []);

  return { currentLocale, switchLocale };
};

Translation Key Management

Translation files organized by locale:

/src/locales/
  ├── en-US.json
  ├── fr-FR.json
  ├── es-ES.json
  └── de-DE.json

i18n Hook Implementation

interface TranslationContextType {
  t: (key: string, params?: Record<string, unknown>) => string;
  locale: string;
  setLocale: (locale: string) => void;
}

const TranslationContext = createContext<TranslationContextType>({
  t: () => '',
  locale: 'en-US',
  setLocale: () => {},
});

const useTranslation = (): TranslationContextType => {
  const context = useContext(TranslationContext);
  if (!context) {
    throw new Error('useTranslation must be used within TranslationProvider');
  }
  return context;
};

const TranslationProvider: React.FC<{ 
  children: React.ReactNode; 
  initialLocale?: string;
}> = ({ children, initialLocale = 'en-US' }) => {
  const [locale, setLocale] = useState<string>(initialLocale);
  const [translations, setTranslations] = useState<Record<string, any>>({});

  useEffect(() => {
    const loadTranslations = async () => {
      try {
        const translationModule = await import(`../locales/${locale}.json`);
        setTranslations(translationModule.default);
      } catch (error) {
        console.error(`Failed to load translations for ${locale}`, error);
      }
    };

    loadTranslations();
  }, [locale]);

  const t = useCallback((key: string, params?: Record<string, unknown>): string => {
    const keys = key.split('.');
    let value = translations;
    
    for (const k of keys) {
      value = value?.[k];
      if (value === undefined) return key;
    }

    if (typeof value === 'string' && params) {
      return Object.entries(params).reduce((str, [key, val]) => 
        str.replace(`{{${key}}}`, String(val)), value);
    }

    return value as string;
  }, [translations]);

  return (
    <TranslationContext.Provider value={{ t, locale, setLocale }}>
      {children}
    </TranslationContext.Provider>
  );
};

Component Integration Example

const LoginForm: React.FC = () => {
  const { t, locale } = useTranslation();
  
  return (
    <div dir={locale.includes('ar') || locale.includes('he') ? 'rtl' : 'ltr'}>
      <h1>{t('auth.login.title')}</h1>
      <label htmlFor="email">{t('auth.login.email_label')}</label>
      <input id="email" type="email" />
      <label htmlFor="password">{t('auth.login.password_label')}</label>
      <input id="password" type="password" />
      <button type="submit">{t('auth.login.submit_button')}</button>
    </div>
  );
};

Testing Strategy

  • Translation key coverage validation using JSON schema
  • RTL layout testing with visual regression tools
  • Locale switching behavior tests in CI pipeline
  • Missing translation detection during build process

12. Monitoring & Observability

Real User Monitoring (RUM) Metrics to Track

For automatic token refresh, we must monitor key performance indicators that directly impact user experience:

  • Token Refresh Success Rate: Percentage of successful token refresh operations vs total attempts
  • Refresh Latency: Time from token expiration to successful refresh completion (target: <200ms)
  • Concurrent Refresh Requests: Number of simultaneous refresh requests to prevent race conditions (target: <3 concurrent)
  • Failed Refresh Rate: Percentage of refresh attempts that result in authentication errors (target: <0.1%)
  • Token Expiry Window: Time between token expiration and refresh initiation (target: <5 seconds)

Error Tracking Integration

We integrate with Sentry for comprehensive error tracking:

  • Authentication Errors: Capture failed refresh attempts, expired tokens, and authentication failures
  • Network Errors: Track failed refresh requests due to connectivity issues or server errors
  • Rate Limiting Events: Monitor when API returns 429 responses during refresh operations
  • Critical Path Failures: Flag refresh failures that prevent user actions (e.g., failed refresh prevents data fetch)

Custom Events and User Journey Tracking

We implement custom tracking for token refresh workflows:

// Token refresh lifecycle events
const trackTokenRefreshEvent = (event: 'start' | 'success' | 'failure', metadata?: any) => {
  // Send to analytics service (e.g., Mixpanel, Amplitude)
  analytics.track('token_refresh', {
    event,
    timestamp: Date.now(),
    ...metadata
  });
};

Performance Monitoring and Regression Detection

  • Refresh Request Duration: Track average time for refresh requests with 95th percentile monitoring
  • Memory Usage: Monitor heap usage during refresh operations to detect memory leaks
  • CPU Utilization: Track refresh operation impact on main thread performance
  • Bundle Size Impact: Monitor token refresh utility bundle size changes over time

Implementation Example

// Performance monitoring hook for token refresh
import { useEffect, useRef } from 'react';

const useTokenRefreshMonitoring = () => {
  const refreshStartTime = useRef<number | null>(null);
  
  const startRefreshTimer = () => {
    refreshStartTime.current = performance.now();
    trackTokenRefreshEvent('start');
  };
  
  const endRefreshTimer = (success: boolean, error?: Error) => {
    if (refreshStartTime.current) {
      const duration = performance.now() - refreshStartTime.current;
      trackTokenRefreshEvent(success ? 'success' : 'failure', {
        duration,
        error: error?.message
      });
      
      // Alert on slow refreshes (>500ms)
      if (duration > 500) {
        console.warn(`Token refresh took ${duration}ms`, { duration });
      }
    }
  };
  
  return { startRefreshTimer, endRefreshTimer };
};

Alerting Strategy

  • High-Failure Rate: Trigger alerts when >1% of refresh attempts fail
  • Slow Response: Alert on 95th percentile refresh times >500ms
  • Concurrent Failures: Alert when >3 simultaneous refresh failures occur
  • Critical Path Degradation: Monitor for 20%+ increase in refresh failure rate over 5-minute window

Data Collection and Privacy Considerations

All monitoring data is collected with user consent and follows privacy regulations:

  • Anonymous tracking of refresh success/failure rates
  • No PII collected in monitoring data
  • User opt-out capability for analytics tracking
  • Data retention policies aligned with privacy regulations

13. File & Folder Structure

Proposed Directory Structure

/src
├── app/
│   ├── layout.tsx
│   ├── page.tsx
│   └── (protected)/
│       ├── layout.tsx
│       └── dashboard/
│           ├── page.tsx
│           └── components/
├── features/
│   ├── auth/
│   │   ├── index.ts
│   │   ├── hooks/
│   │   │   ├── useAuth.ts
│   │   │   └── useTokenRefresh.ts
│   │   ├── services/
│   │   │   └── authService.ts
│   │   ├── utils/
│   │   │   └── tokenUtils.ts
│   │   └── types/
│   │       └── auth.types.ts
├── shared/
│   ├── components/
│   ├── hooks/
│   ├── utils/
│   └── types/
├── lib/
│   ├── api/
│   │   ├── index.ts
│   │   ├── client.ts
│   │   └── interceptors/
│   ├── store/
│   │   └── authStore.ts
│   └── constants/
├── types/
│   └── global.types.ts
└── styles/
    └── globals.css

Module Boundaries and Import Rules

  • Feature isolation: Auth feature is self-contained with its own hooks, services, and types
  • No cross-feature imports: Auth module cannot directly import from other feature modules
  • Shared components: Only presentational components that are truly reusable can be in /shared
  • Barrel exports: Each feature has an index.ts to export the public API

Shared vs Feature-Specific Code Separation

Shared code (reusable across features):

  • Generic utility functions (tokenUtils.ts)
  • Common hooks (useAuth.ts)
  • Reusable components (Button, Modal)
  • Global types (global.types.ts)

Feature-specific code:

  • Auth-specific services and logic
  • Feature-level components and hooks
  • Route-specific business logic

This structure supports clean separation of concerns while enabling easy maintenance and scalability. The feature-based organization makes it simple to understand the auth module's scope and dependencies, while the shared directory ensures proper reuse without tight coupling.

14. Dependencies & Third-Party Libraries

Key Libraries Selected with Justification

axios - Primary HTTP client for API requests

  • Why: Excellent request/response interception, built-in cancelation support, and robust error handling capabilities
  • Bundle size: ~15KB gzipped
  • Features used: Interceptors, request/response transformation, cancelation tokens

react-query - Data fetching and state management

  • Why: Superior caching strategy, automatic refetching, background updates, and built-in stale-while-revalidate patterns
  • Bundle size: ~12KB gzipped
  • Features used: QueryClient, useQuery, useMutation, cache invalidation

zustand - Lightweight state management for non-essential app state

  • Why: Simple API with minimal boilerplate, better performance than Redux for smaller applications, easy to test
  • Bundle size: ~2KB gzipped
  • Features used: Middleware support, async state updates

date-fns - Date/time manipulation library

  • Why: Tree-shakable, immutable, and better performance than moment.js for date operations
  • Bundle size: ~2KB gzipped (with tree-shaking)
  • Features used: Parsing, formatting, timezone handling

i18next - Internationalization framework

  • Why: Production-ready with excellent ecosystem support, lazy loading capabilities, and strong community adoption
  • Bundle size: ~5KB gzipped (core + react integration)
  • Features used: Translation loading, namespace support, fallback mechanisms

Alternatives Considered and Rejected

Redux Toolkit - Rejected due to:

  • Higher boilerplate overhead for simple state management needs
  • More complex setup and configuration than Zustand for our use case
  • Not necessary given the scope of state we need to manage

react-redux - Rejected due to:

  • Complex provider setup and context management
  • More verbose code for simple state access patterns
  • Not justified by our application's complexity

moment.js - Rejected due to:

  • Large bundle size (~20KB) with no tree-shaking support
  • Mutable date objects causing unexpected side effects
  • Performance issues in memory-constrained environments

swr - Rejected due to:

  • Less flexible caching strategy than react-query for our specific use case
  • More limited built-in support for optimistic updates and complex mutations

Licensing and Bundle Size Impact Assessment

axios: MIT license, minimal impact on bundle size react-query: MIT license, moderate bundle impact but justified by feature set zustand: MIT license, minimal impact on bundle size date-fns: MIT license, minimal impact with tree-shaking enabled i18next: MIT license, acceptable bundle size for internationalization needs

Performance Considerations

All selected libraries support tree-shaking to minimize final bundle size. The combination of react-query and zustand provides optimal performance characteristics for our application's state management needs while maintaining clean, maintainable code structure. The chosen libraries have been vetted for compatibility with modern React patterns and provide excellent developer experience with comprehensive documentation and community support.

15. Open Questions, Risks & Trade-offs

Decisions that need product/engineering alignment

  • Token expiration window: What's the acceptable time window between token expiry and refresh? This directly impacts user experience vs. security trade-offs
  • Refresh token rotation policy: Should refresh tokens be rotated on each use? This affects security posture vs. system complexity
  • Concurrent request handling: How should the system handle multiple concurrent requests when a token refresh is in progress? Should they queue or fail?

Known trade-offs made in this design

  • Optimistic refresh vs. blocking: The design prioritizes user experience by attempting optimistic refresh before request failure, but this introduces potential race conditions and complexity in error recovery
  • Client-side token storage: Using localStorage for tokens trades convenience for security - more secure approaches like HttpOnly cookies would require backend changes
  • Automatic refresh window: The 5-minute refresh window provides good UX but increases potential attack surface during that window

Risks and mitigations

  • Token leakage risk: Storing tokens in localStorage makes them vulnerable to XSS attacks. Mitigation: Implement Content Security Policy (CSP) headers and sanitize all user inputs
  • Race condition during refresh: Multiple concurrent requests may trigger multiple refresh operations. Mitigation: Implement request queuing and token sharing mechanism
  • Refresh token theft: If refresh tokens are compromised, attackers can continuously generate new access tokens. Mitigation: Implement refresh token rotation and device fingerprinting
  • Network failure during refresh: Refresh request may fail, leaving the app in an inconsistent state. Mitigation: Implement exponential backoff retry logic and fallback to login flow

Technical debt acknowledged

  • Hardcoded refresh window: The 5-minute window is hardcoded and not configurable, creating technical debt for future feature adjustments
  • Simplified error handling: Current design assumes refresh token is always valid, but real-world scenarios may require more sophisticated error categorization and handling
  • No refresh token validation: The system doesn't validate refresh tokens against backend before using them, which could lead to invalid token usage if tokens are revoked server-side
  • No token expiration monitoring: The system doesn't actively monitor token expiration to trigger refreshes, relying on time-based windowing instead of real-time status checks

Was this article helpful?

Topics in this article

More from Frontend LLD

  • Frontend LLD

    JWT Token Management System Design

    Comprehensive low-level design for secure JWT token handling, refresh mechanisms, and session management in modern web applications.

  • Frontend LLD

    Client-Side Authentication Manager Design

    Comprehensive low-level design for a secure, scalable client-side authentication system with token management and session handling.

  • Backend LLD

    JWT Token Management System Design

    Comprehensive low-level design for a secure JWT token management system with refresh token handling, blacklisting, and distributed session management.

Stay in the Loop

Get curated DSA patterns, coding problems, frontend and backend LLD/HLD, and AI/ML System Design insights delivered straight to your inbox. No spam, just focused interview and engineering content.