React Silent Token Renewal: Auto-Refresh Strategy
Silent token renewal is a proactive strategy where React automatically refreshes the access token before it expires, keeping the user logged in without interruption or a login prompt. Instead of waiting for a 401 error (reactive refresh), the app refreshes 1–3 minutes before expiration (proactive refresh). Combined with the refresh-token pattern, silent renewal provides seamless session management: the user never encounters an expired token unless the refresh itself fails (e.g., the backend is down).
Why Silent Renewal Over Reactive Refresh?
Reactive refresh (waiting for 401) has drawbacks: a brief moment of failed requests, failed mutations (POST/PUT/DELETE) that cannot be easily retried, and poor UX if the token expires while the user is idle. Silent renewal eliminates these issues by refreshing before expiration. According to a 2025 Auth0 survey, 72% of modern SPAs use proactive token refresh for better UX.
Designing the Token Renewal Flow
Timeline Example
Time 0:00 - User logs in
├─ Access token issued: expires at 0:15
└─ Refresh token issued: expires at 7d
Time 0:12 - Silent renewal triggers (3 min before expiry)
├─ App calls /api/refresh
└─ Backend issues new access token: expires at 0:27
Time 0:24 - User is still active; silent renewal triggers again
└─ New access token issued: expires at 0:39
Time 0:40+ - If user is still active, cycle repeats
The key: refresh token validity is much longer (days), so the app can request new access tokens many times without user interaction.
React Custom Hook for Silent Renewal
useTokenRefresh Hook
// useTokenRefresh.js
import { useEffect, useRef, useCallback } from 'react';
/**
* useTokenRefresh: Automatically refresh the access token before expiration.
*
* @param {Object} options - Configuration
* @param {number} options.tokenExpiresIn - Access token lifetime in seconds (e.g., 900 for 15 min)
* @param {number} options.refreshBuffer - Seconds before expiry to trigger refresh (e.g., 180 for 3 min)
* @param {string} options.refreshEndpoint - URL of the refresh endpoint (e.g., '/api/refresh')
* @param {Function} options.onRefreshSuccess - Callback when refresh succeeds
* @param {Function} options.onRefreshFailure - Callback when refresh fails
* @param {boolean} options.enabled - Enable or disable the hook (default: true)
*/
export function useTokenRefresh(options = {}) {
const {
tokenExpiresIn = 15 * 60, // 15 minutes
refreshBuffer = 3 * 60, // Refresh 3 minutes before expiry
refreshEndpoint = '/api/refresh',
onRefreshSuccess = null,
onRefreshFailure = null,
enabled = true
} = options;
const refreshTimeoutRef = useRef(null);
const isRefreshingRef = useRef(false);
// Calculate when to trigger the next refresh
const scheduleRefresh = useCallback(() => {
// Clear any existing timeout
if (refreshTimeoutRef.current) {
clearTimeout(refreshTimeoutRef.current);
}
// Calculate delay: (tokenExpiresIn - refreshBuffer) milliseconds
const delayMs = Math.max(1000, (tokenExpiresIn - refreshBuffer) * 1000);
refreshTimeoutRef.current = setTimeout(async () => {
if (isRefreshingRef.current) {
// A refresh is already in progress; reschedule
scheduleRefresh();
return;
}
isRefreshingRef.current = true;
try {
const response = await fetch(refreshEndpoint, {
method: 'POST',
credentials: 'include', // Include httpOnly cookies
headers: {
'Content-Type': 'application/json'
}
});
if (!response.ok) {
throw new Error(`Refresh failed: ${response.status}`);
}
const data = await response.json();
// Success callback
if (onRefreshSuccess) {
onRefreshSuccess(data);
}
// Schedule the next refresh
scheduleRefresh();
} catch (err) {
console.error('Token refresh failed:', err);
// Failure callback (e.g., redirect to login)
if (onRefreshFailure) {
onRefreshFailure(err);
}
// Optionally, try again in 10 seconds
// scheduleRefresh();
} finally {
isRefreshingRef.current = false;
}
}, delayMs);
}, [tokenExpiresIn, refreshBuffer, refreshEndpoint, onRefreshSuccess, onRefreshFailure]);
// Set up the refresh when the hook mounts or when `enabled` changes
useEffect(() => {
if (!enabled) {
if (refreshTimeoutRef.current) {
clearTimeout(refreshTimeoutRef.current);
}
return;
}
scheduleRefresh();
// Cleanup on unmount
return () => {
if (refreshTimeoutRef.current) {
clearTimeout(refreshTimeoutRef.current);
}
};
}, [enabled, scheduleRefresh]);
// Return a function to manually trigger refresh (useful for testing or edge cases)
return {
refreshNow: scheduleRefresh
};
}
Using the Hook in Your App
// App.js
import React, { useState, useEffect } from 'react';
import { useTokenRefresh } from './useTokenRefresh';
function App() {
const [isLoggedIn, setIsLoggedIn] = useState(false);
const [user, setUser] = useState(null);
// Check if already logged in (by calling /api/me)
useEffect(() => {
async function checkAuth() {
try {
const response = await fetch('/api/me', {
credentials: 'include'
});
if (response.ok) {
const data = await response.json();
setUser(data.user);
setIsLoggedIn(true);
}
} catch (err) {
console.error('Auth check failed:', err);
}
}
checkAuth();
}, []);
// Silent token renewal (only when logged in)
useTokenRefresh({
tokenExpiresIn: 15 * 60, // 15-minute access tokens
refreshBuffer: 3 * 60, // Refresh 3 minutes before expiry
refreshEndpoint: '/api/refresh',
enabled: isLoggedIn, // Only refresh if logged in
onRefreshSuccess: (data) => {
console.log('Token refreshed silently');
},
onRefreshFailure: (err) => {
console.error('Refresh failed; logging out:', err);
setIsLoggedIn(false);
setUser(null);
window.location.href = '/login';
}
});
if (!isLoggedIn) {
return <LoginPage onLoginSuccess={(user) => {
setUser(user);
setIsLoggedIn(true);
}} />;
}
return <Dashboard user={user} />;
}
export default App;
Advanced: Activity-Based Renewal
For better UX, refresh tokens only if the user is active (has interacted with the page recently):
// useActivityBasedTokenRefresh.js
import { useEffect, useRef, useCallback } from 'react';
import { useTokenRefresh } from './useTokenRefresh';
/**
* useActivityBasedTokenRefresh: Renew tokens only if user is active.
* Prevents unnecessary refresh calls for idle users.
*/
export function useActivityBasedTokenRefresh(options = {}) {
const {
inactivityTimeout = 30 * 60 * 1000, // 30 minutes of inactivity logs out
...tokenRefreshOptions
} = options;
const lastActivityRef = useRef(Date.now());
const [isActive, setIsActive] = React.useState(true);
// Track user activity
useEffect(() => {
const handleActivity = () => {
lastActivityRef.current = Date.now();
setIsActive(true);
};
// Listen for user interactions
const events = ['mousedown', 'keydown', 'scroll', 'touchstart'];
events.forEach(event => {
window.addEventListener(event, handleActivity);
});
// Check for inactivity periodically
const inactivityInterval = setInterval(() => {
const now = Date.now();
const timeSinceActivity = now - lastActivityRef.current;
if (timeSinceActivity > inactivityTimeout) {
setIsActive(false);
console.log('User inactive; stopping token refresh');
}
}, 60 * 1000); // Check every minute
return () => {
events.forEach(event => {
window.removeEventListener(event, handleActivity);
});
clearInterval(inactivityInterval);
};
}, [inactivityTimeout]);
// Use the token refresh hook, but only when active
useTokenRefresh({
...tokenRefreshOptions,
enabled: tokenRefreshOptions.enabled && isActive
});
}
Using Activity-Based Renewal
function App() {
const [isLoggedIn, setIsLoggedIn] = useState(false);
useActivityBasedTokenRefresh({
tokenExpiresIn: 15 * 60,
refreshBuffer: 3 * 60,
refreshEndpoint: '/api/refresh',
inactivityTimeout: 30 * 60 * 1000, // Log out after 30 min of inactivity
enabled: isLoggedIn,
onRefreshFailure: () => {
setIsLoggedIn(false);
window.location.href = '/login';
}
});
// ... rest of app
}
Hybrid Approach: Silent + Reactive Refresh
For resilience, combine silent renewal with reactive refresh. If silent renewal fails, the reactive interceptor (from the previous article) will catch the 401 and attempt a refresh:
// authFetch.js with both approaches
let currentAccessToken = null;
export async function authFetch(url, options = {}) {
const headers = {
...options.headers,
'Content-Type': 'application/json'
};
if (currentAccessToken) {
headers['Authorization'] = `Bearer ${currentAccessToken}`;
}
let response = await fetch(url, {
...options,
headers,
credentials: 'include'
});
// Fallback: if 401 despite silent refresh, try refresh again (reactive)
if (response.status === 401) {
try {
const refreshResponse = await fetch('/api/refresh', {
method: 'POST',
credentials: 'include'
});
if (refreshResponse.ok) {
const data = await refreshResponse.json();
currentAccessToken = data.accessToken;
// Retry with new token
headers['Authorization'] = `Bearer ${currentAccessToken}`;
response = await fetch(url, {
...options,
headers,
credentials: 'include'
});
}
} catch (err) {
// If refresh also fails, user must log in
window.location.href = '/login';
}
}
return response;
}
Testing Silent Renewal
Test Case: Simulate Token Expiration
// TestTokenRefresh.js (for testing/debugging)
import { useState } from 'react';
import { useTokenRefresh } from './useTokenRefresh';
export function TestTokenRefresh() {
const [refreshCount, setRefreshCount] = useState(0);
const [lastRefreshTime, setLastRefreshTime] = useState(null);
const { refreshNow } = useTokenRefresh({
tokenExpiresIn: 10, // 10 seconds (short for testing)
refreshBuffer: 3, // Refresh 3 seconds before expiry
refreshEndpoint: '/api/refresh',
enabled: true,
onRefreshSuccess: () => {
setRefreshCount(c => c + 1);
setLastRefreshTime(new Date().toLocaleTimeString());
}
});
return (
<div>
<h2>Token Refresh Test</h2>
<p>Refresh count: {refreshCount}</p>
<p>Last refresh: {lastRefreshTime}</p>
<button onClick={refreshNow}>Refresh Now</button>
</div>
);
}
Key Takeaways
- Silent renewal proactively refreshes tokens before expiration, eliminating 401 errors and login prompts
- Schedule refresh for
tokenExpiresIn - refreshBufferseconds; typical buffer is 1–5 minutes - Always include
credentials: 'include'in the refresh request to send httpOnly cookies - Combine silent renewal with a reactive fallback (catch 401 and refresh again) for resilience
- Only refresh while the user is logged in and active; avoid unnecessary refresh calls for idle users
- Use a timeout/ref to track the refresh schedule; clean up on component unmount
- Log refresh failures; consider notifying the user if continuous refresh fails (backend down)
- Test with short token lifetimes (10–30 seconds) to verify the refresh flow works
Frequently Asked Questions
What if the user is idle and the refresh token expires?
Set an inactivity timeout to log out idle users (e.g., after 30 minutes without interaction). This prevents stale refresh tokens from accumulating. Alternatively, rotate the refresh token on every refresh, invalidating old ones after a short window.
Can silent renewal cause performance issues?
No, if configured correctly. Refresh requests are lightweight (no body, just POST to an endpoint). Spacing them 1–5 minutes apart adds negligible overhead. Test in production to verify; if concerned, log refresh counts and timing.
What if refresh fails repeatedly?
Log the failure and offer the user a choice: redirect to login or retry. If the backend is down, retrying immediately will not help; redirect to login after 1–2 failed attempts to avoid spamming the server.
Should I show a loading indicator during silent refresh?
No. Silent refresh should be invisible; the whole point is to avoid disruption. If you want to signal that something is happening, use a very subtle indicator (e.g., a non-blocking notification). Most users should not notice.
Can I use silent renewal with localStorage tokens?
Not securely. localStorage tokens are vulnerable to XSS, and silent renewal would expose them. Use httpOnly cookies for silent renewal; the browser manages them automatically, and JavaScript cannot steal them.