Flutter Token Refresh Race Condition Crashed Our Node API Login
A dashboard load that fired four parallel API calls triggered a token refresh race condition, logging out half our interns. Here is how we fixed it with a DIO interceptor lock.
Author
The Incident: A Dashboard Load That Logged Out Half Our Interns
It was a Tuesday morning at 09:13 IST. Our Flutter dashboard for the internship program fired four parallel API calls on launch: GET /profile, GET /notifications, GET /settings, and GET /messages. The access token had expired overnight. All four returned 401 Unauthorized. Each one independently triggered a POST /auth/refresh. The first refresh succeeded and invalidated the old refresh token. The next three used the stale refresh token and failed. The error handler interpreted that as a hard logout and cleared the session.
Half the interns were dumped on the login screen. The other half saw a spinner that never resolved.
The Setup: Flutter + DIO + Node/Express + PostgreSQL
Our stack is straightforward. The Flutter client uses DIO for HTTP, flutter_secure_storage for token persistence, and a custom AuthService in /lib/services/auth_service.dart. The backend is Node/Express with PostgreSQL, exposing POST /auth/login, POST /auth/refresh, GET /profile, and GET /notifications. Tokens are JWTs with a 15-minute access expiry and a 7-day refresh expiry.
The Trigger: Parallel API Calls on App Launch
The dashboard is a FutureBuilder that calls four services at once. DIO does not serialize these. They all hit the network within the same event loop tick. When the access token is expired, the server returns 401 for all of them before any refresh has completed.
The Crash: 401s, 4 Refresh Attempts, and a Silent Logout
The logs told the story. Four POST /auth/refresh requests arrived at the Node API within 40 milliseconds. The first returned 200 with a new token. The other three returned 401 because the refresh token was already invalidated. Our interceptor treated any 401 on /auth/refresh as a fatal session error and called clearAll().
What We Tried First (And Why It Failed)
The Stack Overflow Interceptor: One Refresh Per 401
The first attempt was the classic Stack Overflow pattern. Every 401 in the error interceptor triggered its own refresh. That is exactly what caused the race. As Suraj from Suridevs notes, if you see four refresh calls, you have the race condition problem Suridevs.
The Naive Lock: A Single Completer With No State Awareness
The second attempt used a single Completer as a lock. The first request to hit 401 created the completer and started the refresh. The other three awaited completer.future. That worked for the happy path. But when the refresh itself failed, the completer was never completed, and the three waiting requests hung forever.
The Fresh Interceptor: Singleton Pattern Gone Wrong on Android
We tried the fresh package with a singleton interceptor. On iOS it worked. On Android, we hit PlatformException(token_failed, Concurrent operations detected: token, token, null, null). The flutter_appauth issue tracker confirms this is Android-specific and happens when multiple Dio clients share the same Fresh interceptor without a waiting mechanism MaikuB.
The Working Fix: Token Refresh Lock With State Reset
The Lock Pattern: Completer Coordination in DIO Interceptors
The fix uses a shared Completer that coordinates concurrent refresh attempts. When a request gets a 401, it checks if a refresh is already in progress. If so, it awaits the existing completer. If not, it creates a new completer, performs the refresh, and completes the completer with the result. A loggedOut flag short-circuits any further refresh attempts after a fatal failure.
Real Code: AuthService Login With TokenRefreshLock.reset()
// /lib/services/auth_service.dart
class AuthService {
final Dio _dio = Dio();
final TokenManager _tokenManager = TokenManager();
Future<bool> login(String email, String password) async {
final response = await _dio.post('/auth/login', data: {
'email': email,
'password': password,
});
if (response.statusCode == 200) {
await _tokenManager.saveTokens(
accessToken: response.data['access_token'],
refreshToken: response.data['refresh_token'],
);
// Important: clear the logged-out state
TokenRefreshLock.reset();
return true;
}
return false;
}
}
File Paths: /lib/services/auth_service.dart, /lib/network/dio_interceptor.dart
The lock lives in /lib/network/dio_interceptor.dart. It exposes a static Completer? _refreshCompleter, a bool _loggedOut flag, and a reset() method.
API Endpoints: POST /auth/login, POST /auth/refresh, GET /profile, GET /notifications
The interceptor only refreshes on non-auth endpoints. If /auth/refresh itself returns 401, the user is logged out.
Pitfalls We Would Warn an Intern About
Never Refresh on Auth Endpoints Themselves
If /auth/refresh returns 401, do not try to refresh again. That is an infinite loop. Short-circuit and log out.
The Logged-Out Flag: Short-Circuiting Stale Refresh Attempts
After a fatal refresh failure, set _loggedOut = true. Every subsequent 401 should check this flag and skip the refresh logic entirely.
Android PlatformException: Concurrent Operations Detected
On Android, concurrent token operations from multiple Dio clients can trigger PlatformException(token_failed, Concurrent operations detected). Use a singleton interceptor with a queued refresh mechanism MaikuB.
Forgetting to Reset Lock State After Successful Login
If you forget to call TokenRefreshLock.reset() after login, users who were logged out due to refresh failure will be stuck. Their requests will short-circuit on the stale _loggedOut flag.
What We Would Do Differently Next Time
Proactive Token Refresh: Check Expiry Before Sending Requests
Instead of waiting for a 401, check the token expiry in the request interceptor. If the token expires in 10 seconds or less, refresh before sending. This eliminates the race entirely Medium.
Session Version Guard: Prevent Stale Refreshes From Overwriting Sessions
Use a session version counter. Snapshot the version before the refresh. If it changed during the round-trip, discard the result. This prevents a stale refresh from overwriting a newer session Supabase.
Token-Aware Dedup: Map<String, Completer> for Different Refresh Tokens
The old Completer pattern ignored the refreshToken parameter. Use a Map<String, Completer> so same-token calls de-duplicate correctly while different-token calls each run their own request.
Testing the Lock: Simulating 4 Requests Getting 401 Simultaneously
void testTokenRefreshLock() async {
// Simulate 4 requests getting 401 at the same time
final futures = [
simulateExpiredTokenRequest('/profile'),
simulateExpiredTokenRequest('/notifications'),
simulateExpiredTokenRequest('/settings'),
simulateExpiredTokenRequest('/messages'),
];
n await Future.wait(futures);
// Check network inspector:
// - Should see only 1 call to /auth/refresh
// - Should see all 4 original requests retried
}
The expected network log after the fix:
GET /profile -> 401
GET /notifications -> 401
GET /settings -> 401
GET /messages -> 401
POST /auth/refresh -> 200 <- Only one!
GET /profile -> 200 (retried)
GET /notifications -> 200 (retried)
GET /settings -> 200 (retried)
GET /messages -> 200 (retried)
Conclusion: One Refresh Call, Not Four
The race condition was not a bug in our logic. It was a bug in our assumption that each 401 should trigger its own refresh. The fix is a shared Completer that lets one request refresh while the others wait. Add a loggedOut flag, reset on login, and never refresh on auth endpoints. That is one refresh call, not four.
We have shipped this pattern to three client builds since then. No more silent logouts on dashboard load.
Sources
Related reading
Enjoyed this article?
Back to Blog


