Networking in Flutter with Dio: Interceptors, Cancellation, Retries and Tests
Networking in Flutter with Dio: one configured client, auth and logging interceptors, CancelToken, safe retries, DioException mapping, uploads and testing.

The first time I open an unfamiliar Flutter project, I search for Dio(. If it shows up in fifteen files, I know what the next week looks like: timeouts set in some places and not others, auth headers copied by hand, and a logging setup that prints access tokens in release builds.
Dio is my default HTTP client for anything beyond a couple of endpoints. It’s not that the http package is bad. Dio just gives you interceptors, cancellation, progress callbacks and structured errors without extra packages. But it only helps if you set it up once and use it everywhere. This post walks through how I build that one client, step by step.
Step 1: One Dio instance, configured once
Everything starts with a single configured instance. Base URL, timeouts and default headers live here, and nowhere else.
Dio createDio({required String baseUrl, required TokenStore tokens}) {
final dio = Dio(
BaseOptions(
baseUrl: baseUrl,
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 20),
sendTimeout: const Duration(seconds: 20),
headers: {'Accept': 'application/json'},
),
);
dio.interceptors.add(AuthInterceptor(tokens));
if (kDebugMode) {
dio.interceptors.add(LogInterceptor(requestBody: true, responseBody: true));
}
return dio;
}
I register it in whatever dependency setup the project uses (a Riverpod provider, get_it, or a constructor argument) and inject it into API classes. Nothing else in the app calls Dio() directly.
There’s a practical reason for this beyond tidiness. Each Dio instance has its own underlying HTTP client, so creating one per request throws away connection reuse and makes every call pay for a fresh connection setup. It also means a change like adding an app-version header has to be made in one place, not hunted down across the codebase.
The base URL comes from configuration, never a hardcoded string, so dev, staging and production builds point at different backends without code changes. Flavors handle that nicely; see Flutter flavors for dev, staging and production.
A note on timeouts: pick numbers that match your API. A search endpoint that should answer in under a second deserves a shorter timeout than a report export. You can override them per request with Options when needed.
Step 2: Interceptors for auth and logging
Interceptors are Dio’s best feature. They run on every request, response and error, so cross-cutting concerns live in one place.
An auth interceptor that attaches the current token looks like this:
class AuthInterceptor extends Interceptor {
AuthInterceptor(this._tokens);
final TokenStore _tokens;
@override
Future<void> onRequest(
RequestOptions options,
RequestInterceptorHandler handler,
) async {
final token = await _tokens.accessToken();
if (token != null) {
options.headers['Authorization'] = 'Bearer $token';
}
handler.next(options);
}
}
Refreshing an expired token on a 401 is the natural next step, and it’s where things get subtle: several requests can fail at once, and you only want one refresh. Dio’s QueuedInterceptor processes requests one at a time, which helps. I cover the full refresh flow in production-ready authentication in Flutter, so I won’t repeat it here.
For logging, notice the kDebugMode check in step 1. LogInterceptor prints headers and bodies, which means tokens, emails and anything else users send. That’s useful on your machine and a real problem in a release build, where device logs can be read by other tools. Debug only, no exceptions.
Step 3: Cancel requests when the user leaves
A user opens a screen, it starts loading, and they hit back right away. Without cancellation, the request keeps going, the response comes back, and your code tries to update state that no longer exists. At best that wastes data. At worst it causes “setState called after dispose” errors or overwrites newer results.
Dio’s answer is CancelToken:
class SearchController {
SearchController(this._api);
final SearchApi _api;
CancelToken? _inFlight;
Future<List<Product>> search(String query) {
_inFlight?.cancel('superseded');
final token = _inFlight = CancelToken();
return _api.search(query, cancelToken: token);
}
void dispose() => _inFlight?.cancel('disposed');
}
The API method passes the token through:
Future<List<Product>> search(String query, {CancelToken? cancelToken}) async {
final response = await _dio.get<List<dynamic>>(
'/products',
queryParameters: {'q': query},
cancelToken: cancelToken,
);
return response.data!
.map((json) => Product.fromJson(json as Map<String, dynamic>))
.toList();
}
This handles both cases: leaving the screen and typing faster than the server answers. A cancelled request throws a DioException with type cancel. Catch it and ignore it quietly. It isn’t an error from the user’s point of view.
If you use Riverpod, the provider lifecycle does most of this for you. Create a CancelToken inside an auto-disposing provider, register ref.onDispose(token.cancel), and the request is cancelled as soon as nothing is watching the provider anymore, which is usually the moment the user leaves the screen. With BLoC, cancel in the Cubit’s close() method instead.
Step 4: Retry carefully, and only idempotent requests
Retries hide flaky networks well, but they can also do real damage. Retrying a GET is safe: reading the same thing twice changes nothing. Retrying a POST /orders after a timeout might create two orders, because the first one may have succeeded and only the response got lost.
So my retry interceptor only retries requests that are safe to repeat, only for errors that are likely temporary, and with a growing delay between attempts:
class RetryInterceptor extends Interceptor {
RetryInterceptor(this._dio, {this.maxAttempts = 3});
final Dio _dio;
final int maxAttempts;
static const _safeMethods = {'GET', 'HEAD', 'PUT', 'DELETE'};
bool _shouldRetry(DioException e) =>
_safeMethods.contains(e.requestOptions.method.toUpperCase()) &&
(e.type == DioExceptionType.connectionTimeout ||
e.type == DioExceptionType.receiveTimeout ||
e.type == DioExceptionType.connectionError ||
(e.response?.statusCode ?? 0) >= 500);
@override
Future<void> onError(
DioException err,
ErrorInterceptorHandler handler,
) async {
final attempt = (err.requestOptions.extra['attempt'] as int?) ?? 1;
if (!_shouldRetry(err) || attempt >= maxAttempts) {
handler.next(err);
return;
}
await Future<void>.delayed(Duration(milliseconds: 400 * (1 << attempt)));
err.requestOptions.extra['attempt'] = attempt + 1;
try {
handler.resolve(await _dio.fetch<dynamic>(err.requestOptions));
} on DioException catch (e) {
handler.next(e);
}
}
}
PUT and DELETE are idempotent by definition, but only if your backend actually implements them that way. Check before trusting the list. If you need to retry a POST, have the backend accept an idempotency key so it can recognize duplicates. That’s a backend change, not something the client can fake.
Step 5: Map DioException into something the app understands
Every Dio failure arrives as a DioException with a type. The UI shouldn’t see those. I map them to app-level failures at the API boundary:
Failure mapDioException(DioException e) => switch (e.type) {
DioExceptionType.connectionTimeout ||
DioExceptionType.sendTimeout ||
DioExceptionType.receiveTimeout ||
DioExceptionType.connectionError =>
const NetworkFailure(),
DioExceptionType.badResponse => switch (e.response?.statusCode) {
401 || 403 => const UnauthorizedFailure(),
422 => const ValidationFailure({}),
_ => const UnexpectedFailure(),
},
DioExceptionType.cancel ||
DioExceptionType.badCertificate ||
DioExceptionType.unknown =>
const UnexpectedFailure(),
};
In a real app the 422 branch would parse field errors from the response body. Since the switch over DioExceptionType is exhaustive, the compiler tells you if a future Dio version adds a type. The bigger picture of turning failures into messages and retry buttons is in error handling in Flutter apps.
Step 6: Uploads with progress
Uploading a photo or document is where Dio really earns its place. FormData builds a multipart body, and onSendProgress reports bytes sent:
Future<void> uploadAvatar(
String filePath, {
required void Function(double progress) onProgress,
CancelToken? cancelToken,
}) async {
final form = FormData.fromMap({
'file': await MultipartFile.fromFile(filePath, filename: 'avatar.jpg'),
});
await _dio.post<void>(
'/me/avatar',
data: form,
cancelToken: cancelToken,
onSendProgress: (sent, total) {
if (total > 0) onProgress(sent / total);
},
);
}
Pass a CancelToken here too, and give users a cancel button for large files. And compress images before uploading. A phone camera’s full-resolution photo is far bigger than any avatar needs to be.
Step 7: Test without a network
Network code should be testable without a server. There are two levels I use.
At the repository level, I don’t touch Dio at all. The repository depends on an API interface, and tests pass a fake implementation. That covers most business logic.
To test the Dio layer itself (interceptors, error mapping, JSON parsing), I swap the client’s httpClientAdapter for a fake that returns canned responses:
class FakeAdapter implements HttpClientAdapter {
FakeAdapter(this.body, {this.statusCode = 200});
final String body;
final int statusCode;
@override
Future<ResponseBody> fetch(
RequestOptions options,
Stream<Uint8List>? requestStream,
Future<void>? cancelFuture,
) async {
return ResponseBody.fromString(
body,
statusCode,
headers: {
Headers.contentTypeHeader: [Headers.jsonContentType],
},
);
}
@override
void close({bool force = false}) {}
}
Then dio.httpClientAdapter = FakeAdapter('[{"id": "1", "name": "Mug"}]') and the real interceptors and parsing run against it. The http_mock_adapter package offers a more complete version of this idea if you want to match specific routes.
Takeaways
- Create one configured Dio instance and inject it. Nothing else should call
Dio(). - Put auth and logging in interceptors, and only add
LogInterceptorin debug builds. - Use
CancelTokenfor screens the user can leave and for search-as-you-type. - Retry only idempotent requests and temporary failures, with a growing delay.
- Map
DioExceptionto app-level failures at the API boundary so the UI never sees Dio types. - Test parsing and interceptors with a fake adapter, and business logic with a fake API.
Set it up well once, and networking becomes the boring part of the app, which is exactly what it should be.
Comments
Questions, corrections or your own experience — leave a comment below (GitHub sign-in).