SuriDevs Logo
Flutter DIO interceptor flow showing four parallel API requests hitting a single token refresh lock instead of triggering four duplicate refresh calls

Flutter DIO Token Refresh: Fix the Race Condition (Part 2)

By Sagar Maiyad  Jan 24, 2026

This guide is self-contained — read it on its own and you'll have a working token-refresh system by the end. It's also Part 2 of a Flutter DIO networking series: if you want the surrounding ApiConfig and ApiResponse layers, Part 1: Foundation covers them; for the repository pattern that consumes the DIO client built here, see Part 3: Clean API Calls.

Let's run an experiment.

Open your app. Make sure you're logged in. Now, find where your dashboard loads data — probably something like this:

@override
void initState() {
  super.initState();
  _loadProfile();
  _loadNotifications();
  _loadRecentActivity();
  _loadUnreadMessages();
}

Four API calls. Normal stuff.

Now, expire your access token manually (or wait until it expires naturally) and refresh the page.

Open your network inspector. Count how many times /auth/refresh gets called.

If you see 4 refresh calls, you have the race condition problem. And if you're using a typical token refresh implementation from Stack Overflow, you probably do.

Here's what happens:

  1. All 4 requests fire simultaneously
  2. All 4 get 401 Unauthorized (token expired)
  3. All 4 try to refresh the token
  4. Server processes 4 refresh requests
  5. First one succeeds, returns new token
  6. Next 3 use the OLD refresh token (already invalidated)
  7. Those 3 fail, potentially logging out the user

I've seen this bug in production apps with millions of users. It's subtle — it doesn't happen every time. Just enough to generate confused user reports about "random logouts."

Let's fix it properly. The fix uses flutter_secure_storage for token persistence, DIO interceptors for the auth flow, and a single shared Completer to coordinate concurrent refresh attempts.

What this post covers (jump to any section):

Step 1: Secure Token Storage

First, where are you storing tokens right now?

// If you're doing this, stop
SharedPreferences.setString('access_token', token);

SharedPreferences is not secure:

  • On Android: plain XML file in app directory
  • On iOS: plist file, unencrypted
  • On rooted/jailbroken devices: readable by other apps

For auth tokens, use platform secure storage:

// lib/core/services/token_manager.dart

import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

class TokenManager {
  static const _storage = FlutterSecureStorage(
    aOptions: AndroidOptions(
      encryptedSharedPreferences: true,
    ),
    iOptions: IOSOptions(
      accessibility: KeychainAccessibility.first_unlock,
    ),
  );

  static const String _accessTokenKey = 'access_token';
  static const String _refreshTokenKey = 'refresh_token';
  static const String _userIdKey = 'user_id';

  Future<void> saveTokens({
    required String accessToken,
    String? refreshToken,
  }) async {
    await _storage.write(key: _accessTokenKey, value: accessToken);
    if (refreshToken != null) {
      await _storage.write(key: _refreshTokenKey, value: refreshToken);
    }
  }

  Future<String?> getAccessToken() async {
    return await _storage.read(key: _accessTokenKey);
  }

  Future<String?> getRefreshToken() async {
    return await _storage.read(key: _refreshTokenKey);
  }

  Future<void> saveUserId(String userId) async {
    await _storage.write(key: _userIdKey, value: userId);
  }

  Future<String?> getUserId() async {
    return await _storage.read(key: _userIdKey);
  }

  Future<bool> isAuthenticated() async {
    final token = await getAccessToken();
    final userId = await getUserId();
    return token != null && token.isNotEmpty && userId != null;
  }

  Future<void> clearAll() async {
    await _storage.deleteAll();
  }
}

// Riverpod provider
final tokenManagerProvider = Provider<TokenManager>((ref) => TokenManager());

"What does encryptedSharedPreferences: true do?"

On Android 6.0+, it uses the Android Keystore to encrypt the data. Even on rooted devices, the tokens aren't readable as plain text.

"What's KeychainAccessibility.first_unlock?"

On iOS, it means the data is accessible after the user unlocks the device once after boot. It's a balance between security and usability. Other options:

Option When Accessible Use Case
first_unlock After first unlock since boot Most apps
unlocked Only when device is unlocked High security
always Always, even when locked Background refresh (less secure)

For auth tokens, first_unlock is the standard choice.

Step 2: The Token Refresh Lock

This is the solution to the race condition. One class, 40 lines:

// lib/core/network/token_refresh_lock.dart

import 'dart:async';

/// Ensures only one token refresh happens at a time.
///
/// When 4 requests get 401 simultaneously:
/// - First request acquires the lock, starts refreshing
/// - Requests 2, 3, 4 see lock is taken, wait for the result
/// - First request completes, all 4 get the new token
/// - All 4 retry their original request with the new token
class TokenRefreshLock {
  static Completer<String>? _refreshCompleter;
  static bool _isLoggedOut = false;

  /// Is a refresh currently in progress?
  static bool get isRefreshing => _refreshCompleter != null;

  /// Has the user been logged out due to refresh failure?
  static bool get isLoggedOut => _isLoggedOut;

  /// Try to acquire the lock.
  /// Returns a Completer if you got the lock (you should refresh).
  /// Returns null if someone else is already refreshing (you should wait).
  ///
  /// Note: Dart's event loop is single-threaded, so this check-then-set
  /// is atomic. There is no `await` between the null check and the
  /// assignment, so no other coroutine can interleave and acquire the
  /// lock at the same time. In a multi-threaded language you'd need a
  /// mutex here — in Dart, the event loop *is* the mutex.
  static Completer<String>? tryAcquire() {
    if (_refreshCompleter == null && !_isLoggedOut) {
      _refreshCompleter = Completer<String>();
      return _refreshCompleter;
    }
    return null;
  }

  /// Get the current completer to wait on.
  static Completer<String>? get currentCompleter => _refreshCompleter;

  /// Call when refresh succeeds. Waiting requests will receive the new token.
  static void complete(String newToken) {
    _refreshCompleter?.complete(newToken);
    _refreshCompleter = null;
  }

  /// Call when refresh fails. Waiting requests will receive the error.
  static void completeWithError(Object error) {
    _refreshCompleter?.completeError(error);
    _refreshCompleter = null;
    _isLoggedOut = true;  // Prevent further refresh attempts
  }

  /// Reset state (call after successful login).
  ///
  /// If a refresh is somehow still in flight when reset is called,
  /// fail any awaiting requests with an explicit error rather than
  /// silently nulling the completer — that would leak coroutines
  /// stuck on `completer.future` forever.
  static void reset() {
    _refreshCompleter?.completeError(StateError('Refresh lock reset'));
    _refreshCompleter = null;
    _isLoggedOut = false;
  }
}

"Why a Completer instead of a simple boolean lock?"

Because the waiting requests need to receive the new token once the refresh completes. A Completer lets them do this:

// Request 2, 3, 4 do this:
final newToken = await TokenRefreshLock.currentCompleter!.future;
// Now they have the new token without making another refresh call

If we used a boolean, they'd have to poll: "Is it done yet? Is it done yet?" That's wasteful and introduces timing bugs.

"What's the _isLoggedOut flag for?"

Once a refresh fails (refresh token expired or revoked), there's no point trying again. Every subsequent 401 should just fail immediately. The user needs to log in again — no amount of retrying will fix it.

When that happens, the UI side needs to surface it cleanly — usually a snackbar saying "Your session expired" plus a redirect to the login screen, not a full-page error that confuses the user. Showing the right error state for auth failures is its own pattern on top of this network layer. And if you have multiple providers that need to react when the logged-out flag flips, syncing state across Riverpod providers covers that exact handoff.

Step 3: DIO Provider with Interceptors

Now we wire everything together. This is the largest file, but every line has a purpose:

// lib/core/network/dio_provider.dart

import 'dart:io';
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'api_config.dart';
import 'token_refresh_lock.dart';
import '../services/token_manager.dart';

final dioProvider = Provider<Dio>((ref) {
  final tokenManager = ref.read(tokenManagerProvider);

  final dio = Dio(
    BaseOptions(
      baseUrl: ApiConfig.fullBaseUrl,
      connectTimeout: ApiConfig.connectTimeout,
      receiveTimeout: ApiConfig.receiveTimeout,
      sendTimeout: ApiConfig.sendTimeout,
      headers: ApiConfig.defaultHeaders,
    ),
  );

  dio.interceptors.add(
    InterceptorsWrapper(
      // ────────────────────────────────────────────────────
      // REQUEST INTERCEPTOR
      // Runs before every request goes out
      // ────────────────────────────────────────────────────
      onRequest: (options, handler) async {
        final token = await tokenManager.getAccessToken();

        if (token != null && token.isNotEmpty) {
          options.headers['Authorization'] = 'Bearer $token';
        }

        // Optional: identify platform for backend analytics
        if (Platform.isAndroid) {
          options.headers['X-Platform'] = 'android';
        } else if (Platform.isIOS) {
          options.headers['X-Platform'] = 'ios';
        }

        return handler.next(options);
      },

      // ────────────────────────────────────────────────────
      // ERROR INTERCEPTOR
      // Runs when a request fails
      // ────────────────────────────────────────────────────
      onError: (error, handler) async {
        // Only handle 401 Unauthorized
        if (error.response?.statusCode != 401) {
          return handler.next(error);
        }

        // Critical: dio.fetch() re-runs the full interceptor chain.
        // If the retried request also returns 401 (e.g., new token was
        // immediately invalidated), we'd re-enter this interceptor,
        // acquire the lock again, refresh again — forever.
        // Mark retries via RequestOptions.extra to short-circuit on
        // the second pass through.
        if (error.requestOptions.extra['__retried'] == true) {
          return handler.next(error);
        }

        final requestPath = error.requestOptions.path;

        // Never try to refresh when the auth endpoints themselves fail.
        // If /auth/refresh returns 401, we're done — user needs to log in again.
        // If /auth/login returns 401, that's just wrong credentials.
        if (requestPath.contains('/auth/login') ||
            requestPath.contains('/auth/refresh') ||
            requestPath.contains('/auth/register')) {
          return handler.next(error);
        }

        // Already logged out from a previous refresh failure?
        // Don't even try.
        if (TokenRefreshLock.isLoggedOut) {
          return handler.reject(error);
        }

        // ── Try to acquire the refresh lock ──
        final acquiredLock = TokenRefreshLock.tryAcquire();

        if (acquiredLock == null) {
          // Someone else is already refreshing. Wait for them.
          final completer = TokenRefreshLock.currentCompleter;

          if (completer != null) {
            try {
              // Wait for the other request to finish refreshing
              final newToken = await completer.future;

              // Retry our original request with the new token.
              // Mark as retried so a second 401 doesn't loop us back here.
              error.requestOptions.headers['Authorization'] = 'Bearer $newToken';
              error.requestOptions.extra['__retried'] = true;
              final response = await dio.fetch(error.requestOptions);
              return handler.resolve(response);
            } catch (e) {
              // The refresh failed. Our request fails too.
              return handler.reject(error);
            }
          }
          return handler.reject(error);
        }

        // ── We got the lock. Time to refresh. ──
        try {
          final refreshToken = await tokenManager.getRefreshToken();

          if (refreshToken == null) {
            throw Exception('No refresh token available');
          }

          // Call the refresh endpoint
          final refreshResponse = await dio.post(
            '/auth/refresh',
            data: {'refresh_token': refreshToken},
          );

          // Extract token (adjust based on your API's response format).
          // Pulled out so the null-token failure path is explicit, not
          // tucked inside a fall-through `throw`.
          final newToken = refreshResponse.statusCode == 200
              ? (refreshResponse.data['access_token'] ??
                  refreshResponse.data['token'] ??
                  refreshResponse.headers
                      .value('authorization')
                      ?.replaceFirst('Bearer ', ''))
              : null;

          if (newToken == null || (newToken is String && newToken.isEmpty)) {
            throw Exception('Token refresh failed: no token in response');
          }

          // Save the new tokens
          await tokenManager.saveTokens(
            accessToken: newToken,
            refreshToken: refreshResponse.data['refresh_token'],
          );

          // Release the lock — waiting requests get the new token
          TokenRefreshLock.complete(newToken);

          // Retry our original request. Same retry guard as above.
          error.requestOptions.headers['Authorization'] = 'Bearer $newToken';
          error.requestOptions.extra['__retried'] = true;
          final response = await dio.fetch(error.requestOptions);
          return handler.resolve(response);
        } catch (e) {
          // Refresh failed. Release the lock with error.
          TokenRefreshLock.completeWithError(e);

          // Clear stored tokens — user is logged out
          await tokenManager.clearAll();

          // Reject with a clear error
          return handler.reject(
            DioException(
              requestOptions: error.requestOptions,
              error: 'Session expired. Please log in again.',
              type: DioExceptionType.badResponse,
            ),
          );
        }
      },
    ),
  );

  // Debug logging for development only
  if (!ApiConfig.isProduction) {
    dio.interceptors.add(
      LogInterceptor(
        requestBody: true,
        responseBody: true,
        logPrint: (obj) => print('🌐 $obj'),
      ),
    );
  }

  return dio;
});

Here is the step-by-step error interceptor flow for a 401 response:

  1. Request fails with 401 Unauthorized. Anything other than 401 falls through to the next handler — only auth failures trigger refresh logic.
  2. Is the failing request itself an auth endpoint (/auth/login, /auth/refresh, /auth/register)? If yes, pass through without refreshing — a 401 on /auth/refresh means the refresh token itself is dead.
  3. Has the user already been logged out from a previous failed refresh? If yes, reject immediately — no point trying again.
  4. Has this specific request already been retried once (extra['__retried'] == true)? If yes, let it fail — this is the guard that prevents dio.fetch() from looping the interceptor forever.
  5. Try to acquire the refresh lock. Only one request can hold the lock at a time.
    • Lock not acquired: another request is already refreshing. await completer.future for the new token, mark this request as retried, and retry with the new token via dio.fetch().
    • Lock acquired: proceed to step 6.
  6. Call /auth/refresh with the stored refresh token.
  7. Extract the new access token from the response (try access_token, then token, then the Authorization header).
  8. If extraction fails (null/empty token, or non-200 status): release the lock via completeWithError(), clear all stored tokens via clearAll(), and reject the original request with a "Session expired" error.
  9. If extraction succeeds: save the new tokens, release the lock via complete(newToken) so any waiting requests wake up with the new token, mark the original request as retried, and retry it via dio.fetch().

Don't Forget: Reset on Login

When a user logs in successfully, reset the lock:

// In your AuthService or LoginViewModel
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;
}

If you forget this, users who were logged out due to refresh failure won't be able to make authenticated requests even after logging in again.

Testing the Race Condition Fix

Here's how to verify it works:

void testTokenRefreshLock() async {
  // Simulate 4 requests getting 401 at the same time
  final results = await Future.wait([
    simulateExpiredTokenRequest('/profile'),
    simulateExpiredTokenRequest('/notifications'),
    simulateExpiredTokenRequest('/settings'),
    simulateExpiredTokenRequest('/messages'),
  ]);

  // Check network inspector:
  // - Should see only 1 call to /auth/refresh
  // - Should see all 4 original requests retried
}

In your network inspector, you should see:

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)

If you see 4 refresh calls, something's wrong with the lock implementation.

What We Have Now

lib/
└── core/
    ├── network/
    │   ├── api_config.dart
    │   ├── api_response.dart
    │   ├── api_error_handler.dart
    │   ├── token_refresh_lock.dart  ← NEW
    │   └── dio_provider.dart        ← NEW
    └── services/
        └── token_manager.dart       ← NEW
File Responsibility
token_manager.dart Secure token storage
token_refresh_lock.dart Coordinate concurrent refresh attempts
dio_provider.dart Configure DIO with auth interceptors

What's Next

In Part 3, we build the final layer: BaseApiService and repository pattern. This is where we get those clean API calls:

final response = await userRepository.getProfile();
if (response.success) {
  // done
}

We'll also put together the complete flow diagram and a checklist for production deployment.

Checklist Before Moving On

  • [ ] Tokens stored in FlutterSecureStorage, not SharedPreferences
  • [ ] TokenRefreshLock.reset() called after successful login
  • [ ] Auth endpoints (/login, /refresh, /register) excluded from refresh logic
  • [ ] Debug logging only in non-production builds
  • [ ] Tested with multiple simultaneous 401s — only 1 refresh call

See you in Part 3.


This is Part 2 of a 3-part series:

  • Part 1: Foundation — ApiConfig, ApiResponse, ErrorHandler
  • Part 2: Token Refresh (you're here) — TokenManager, TokenRefreshLock, DIO Interceptors
  • Part 3: Clean API Calls — BaseApiService, Repository pattern

Frequently asked questions

Why does Flutter DIO call /auth/refresh multiple times when the token expires?

Because every request that gets a 401 independently triggers the error interceptor, and a naive implementation kicks off its own refresh. If a dashboard fires 4 parallel API calls and the token has just expired, you get 4 simultaneous /auth/refresh calls. The first one succeeds and invalidates the refresh token, the other 3 use the now-stale refresh token and fail — which usually logs the user out. The fix is a refresh lock (a Completer) that lets one request refresh while the others wait for the new token.

How do I prevent duplicate token refresh requests in Flutter?

Use a static Completer<String> as a refresh lock. The first 401 acquires the lock and starts the refresh; subsequent 401s see the lock is held and await the same Completer's future. When the refresh completes, all waiting requests receive the new token from the Completer and retry their original request. This is significantly more efficient than a boolean flag plus polling, and it propagates errors cleanly if the refresh itself fails.

Should I store auth tokens in SharedPreferences in Flutter?

No. SharedPreferences is plain XML on Android and an unencrypted plist on iOS — readable on rooted or jailbroken devices and recoverable from device backups. Use flutter_secure_storage instead: on Android it wraps the Keystore (set encryptedSharedPreferences: true), and on iOS it wraps Keychain (KeychainAccessibility.first_unlock is the standard choice for auth tokens). The API is almost identical to SharedPreferences but the tokens are encrypted at rest.

What happens if the refresh token itself expires?

The /auth/refresh call returns 401, the catch block fires, clearAll() wipes the stored tokens, and the lock is marked as logged-out so subsequent 401s short-circuit instead of trying to refresh again. The UI layer needs to listen for this and redirect to the login screen — typically by surfacing the error through a Riverpod provider and showing a session-expired snackbar. Without the logged-out flag, every queued request would keep attempting refreshes that can't possibly succeed.

Flutter DIO Authentication Token Refresh Dart

Author

Sagar Maiyad
Written By
Sagar Maiyad

Sagar Maiyad - Android developer specializing in Kotlin, Jetpack Compose, and modern Android architecture. Sharing practical tutorials and real-world development insights.

View All Posts →

Related Posts

Latest Tags