Authentication is a foundational requirement for most apps. In Flutter, a common pattern uses JWTs (JSON Web Tokens) or similar tokens to authenticate requests. At first, you might rely on a single access token—but as your app scales, you’ll quickly need a refresh token strategy to avoid forcing users to log in over and over.
In this article, we’ll move through three stages of an authentication flow:
- A simple app with only an access token: Suitable for short-lived demos or backends that never expire tokens.
- A refresh token flow with “One Future”: Ensures only one refresh request is triggered, but can get clunky with concurrency.
- Drawbacks of “One Future” and how to optimize using a flag + queue approach for high concurrency.
Let’s dive in!
1) The Simplest Setup: Only an Access Token
Imagine you have an API that doesn’t expire tokens—or your app’s token can be reissued manually. Your Dio interceptor might look like this:
class SimpleAuthInterceptor extends Interceptor {
  final Dio dio;
  final SecureStorage secureStorage; // or any storage
  SimpleAuthInterceptor(this.dio, this.secureStorage);
  @override
  Future<void> onRequest(RequestOptions options, RequestInterceptorHandler handler) async {
    final token = await secureStorage.readToken();
    if (token != null && token.isNotEmpty) {
      options.headers['Authorization'] = 'Bearer $token';
    }
    handler.next(options);
  }
}
Here:
- We read an access token from storage.
- Attach it to every request in the Authorization: Bearer ...header.
- If the token is invalid or expires, we rely on the backend to tell us (maybe a 401), at which point we might just fail gracefully or prompt the user to re-log in.
Pros:
- Very simple—no refresh logic, no concurrency issues.
Cons:
- The user must log in again every time the token expires. That’s extremely annoying if tokens expire frequently.
- If the server can revoke your token unpredictably, you have no fallback other than a 401.
This works for minimal demos or local dev, but in real production apps, having only an access token is rarely enough.
2) Introducing a Refresh Token Using the “One Future” Approach
When your access token can expire, a refresh token is typically provided by the backend. This refresh token can be used to request a new access token without prompting the user for credentials again. In Flutter/Dio, we can code it like so:
- In onRequest, attach the current access token.
- If a request fails with 401, check if we’re already refreshing.
- If no refresh is in progress, start a single refresh request (our “one future”).
- If a refresh is already in progress, wait for that same future.
- Once the refresh completes, retry the original request with the new token.
Sample Code
class OneFutureAuthInterceptor extends Interceptor {
  final Dio dio;
  Future<String?>? _refreshTokenFuture;
  final TokenStorage tokenStorage; // a class that reads/writes tokens
  OneFutureAuthInterceptor(this.dio, this.tokenStorage);
  @override
  Future<void> onRequest(RequestOptions options, RequestInterceptorHandler handler) async {
    final accessToken = await tokenStorage.readAccessToken();
    if (accessToken != null && accessToken.isNotEmpty) {
      options.headers['Authorization'] = 'Bearer $accessToken';
    }
    handler.next(options);
  }
  @override
  Future<void> onError(DioException err, ErrorInterceptorHandler handler) async {
    if (_isUnauthorized(err) && _shouldRefresh(err.requestOptions)) {
      // Attempt refresh if not already happening
      _refreshTokenFuture ??= _refreshAccessToken();
      final newToken = await _refreshTokenFuture;
      if (newToken != null) {
        // Retry the original request
        final clonedRequest = _retryRequest(err.requestOptions, newToken);
        try {
          final response = await dio.fetch(clonedRequest);
          return handler.resolve(response);
        } catch (e) {
          return handler.next(e as DioException);
        }
      }
      // If refresh fails, newToken == null => pass the 401 up
    }
    return handler.next(err);
  }
  bool _isUnauthorized(DioException err) {
    return err.response?.statusCode == 401;
  }
  bool _shouldRefresh(RequestOptions requestOptions) {
    // Avoid refreshing again if it's the refresh token call
    return !requestOptions.path.contains('/refresh');
  }
  RequestOptions _retryRequest(RequestOptions requestOptions, String newToken) {
    final newHeaders = Map<String, dynamic>.from(requestOptions.headers);
    newHeaders['Authorization'] = 'Bearer $newToken';
    return requestOptions.copyWith(headers: newHeaders);
  }
  Future<String?> _refreshAccessToken() async {
    try {
      // Call your /refresh endpoint
      // final response = await Dio().post('https://your.api/refresh', data: {...});
      // final newAccessToken = response.data['accessToken'];
      final newAccessToken = 'FAKE_NEW_TOKEN';
      await tokenStorage.saveAccessToken(newAccessToken);
      return newAccessToken;
    } catch (e) {
      // If fail, remove token or force user to re-log
      await tokenStorage.clearTokens();
      return null;
    } finally {
      // Allow future refresh attempts next time 401 is encountered
      _refreshTokenFuture = null;
    }
  }
}
How It Works
- 
_refreshTokenFuture ensures only one refresh call is triggered even if multiple requests fail with 401simultaneously.
- Once the refresh request completes, any other request that was also waiting for a new token will just awaitthe same future.
Pros
- Concise code: “One Future” is straightforward and easy to follow.
- 
No complicated data structures: We rely on a single _refreshTokenFuture.
Cons
- 
Concurrency edge cases: If 20 requests simultaneously get a 401, each one callsonError, you must be careful about setting_refreshTokenFuturequickly to avoid double refresh attempts.
- 
No “batch control”: Each request effectively retries itself. If you want to reorder, skip, or handle certain requests differently, you’ll do it in multiple places (onErrorfor each request).
- All or nothing: If the refresh fails, every request that was waiting sees the error at once.
For many small to moderate apps, “One Future” is a nice balance of simplicity and concurrency control.
3) Drawbacks & the Flag + Queue Optimization
While “One Future” works well for moderate concurrency, it can get messy if you:
- Fire lots of parallel API calls at once.
- Need to conditionally drop or batch certain calls.
- Want advanced concurrency controls, e.g. reordering requests or limiting the maximum concurrency.
In that scenario, the “One Future” approach can cause:
- 
Repetitive Logic: Each request that hits onError(401)re-applies the same “clone & retry” code. If you need special handling (like changing request parameters before retry), that code can get scattered in multiple places.
- 
Difficult “group management”: Suppose you want to say, “Cancel half of the requests if the user is no longer on that screen,” or “Retry these requests in a specific sequence.” You end up patching code in each individual onError.
The Flag + Queue Approach
Solution: Use a flag (like _isRefreshing) and a queue (_pendingRequests) to store all failing requests in one place. Then:
- On 401, push that request into_pendingRequests.
- If _isRefreshing == false, start the refresh flow; otherwise, keep queueing more requests.
- After refreshing, loop through _pendingRequestsand retry them all in one centralized method.
- If refresh fails, fail them all at once and log out the user.
Yes, it’s more code to set up. But it:
- Centralizes concurrency. You see exactly which requests are pending.
- Lets you handle “group logic” easily (like partial cancellation, request ordering, or “one big retry batch”).
- Minimizes race conditions because the moment you set _isRefreshing = true, you know no second refresh will start.
Quick Snippet
class AuthInterceptorQueue extends Interceptor {
  bool _isRefreshing = false;
  final List<_PendingRequest> _pendingRequests = [];
  // ...
  @override
  Future<void> onError(DioException err, ErrorInterceptorHandler handler) async {
    if (_isUnauthorized(err)) {
      _pendingRequests.add(_PendingRequest(err.requestOptions, Completer<Response>()));
      if (!_isRefreshing) {
        _isRefreshing = true;
        final refreshed = await _refreshToken();
        if (refreshed) {
          await _retryAllRequests();
        } else {
          _failAllRequests(err);
        }
        _isRefreshing = false;
      }
      // The request that triggered 401 waits on a completer
      try {
        final last = _pendingRequests.last;
        final response = await last.completer.future;
        return handler.resolve(response);
      } catch (e) {
        return handler.next(e as DioException);
      }
    }
    return super.onError(err, handler);
  }
  // ...
}
If you’re curious about the full example, check out other resources or see the “Flag + Queue” approach in detail. In many large apps, it’s a safer concurrency strategy.
Conclusion
We’ve walked through three authentication flows in Flutter with Dio:
- Only Access Token: Simplest but forces re-login on token expiration.
- Refresh Flow Using “One Future”: Short code, minimal overhead, good for moderate concurrency.
- Shortcomings & “Flag + Queue”: More code but handles concurrency thoroughly, letting you batch or reorder requests.
Which approach you choose depends on your app’s complexity and concurrency needs:
- For a small or medium-scale app, “One Future” might be perfect.
- For heavy concurrency or advanced request management, a “Flag + Queue” approach might give you better control.
Either way, placing your token logic inside a Dio interceptor is a clean design, isolating authentication concerns from the rest of your app. Happy coding!
Further Reading
- Dio GitHub
- Flutter Secure Storage for token security.
- JWT best practices for properly handling token lifecycles.
That’s it! If you have any questions or suggestions, please drop a comment below. Thanks for reading!
 

 
    
Top comments (0)