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
401
simultaneously. - Once the refresh request completes, any other request that was also waiting for a new token will just
await
the 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_refreshTokenFuture
quickly 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 (
onError
for 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
_pendingRequests
and 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)