← Articles
HTTP networking in Flutter with Dio
By John · 8 August 2025
Dio is the de facto standard HTTP client for Flutter. Its interceptor system makes it easy to add auth headers, log requests, handle token refresh, and retry failed requests — all in a clean, composable way.
Setup
dependencies:
dio: ^5.4.3
retrofit: ^4.1.0 # Optional: type-safe API client
json_annotation: ^4.9.0
dev_dependencies:
retrofit_generator: ^8.1.0
build_runner: ^2.4.9
Basic configuration
Dio buildDio() {
return Dio(
BaseOptions(
baseUrl: AppConfig.apiBaseUrl,
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 30),
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-Client-Version': AppConfig.version,
},
),
);
}
Auth interceptor
class AuthInterceptor extends Interceptor {
AuthInterceptor(this._authService);
final AuthService _authService;
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) async {
final token = await _authService.getAccessToken();
if (token != null) {
options.headers['Authorization'] = 'Bearer $token';
}
handler.next(options);
}
@override
void onError(DioException err, ErrorInterceptorHandler handler) async {
if (err.response?.statusCode == 401) {
// Try to refresh the token
try {
final newToken = await _authService.refreshToken();
// Retry the original request with the new token
err.requestOptions.headers['Authorization'] = 'Bearer $newToken';
final response = await Dio().fetch(err.requestOptions);
return handler.resolve(response);
} catch (_) {
// Refresh failed — sign out
await _authService.signOut();
return handler.next(err);
}
}
handler.next(err);
}
}
Logging interceptor
class LoggingInterceptor extends Interceptor {
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
debugPrint('→ ${options.method} ${options.uri}');
if (options.data != null) debugPrint(' Body: ${options.data}');
handler.next(options);
}
@override
void onResponse(Response response, ResponseInterceptorHandler handler) {
debugPrint('← ${response.statusCode} ${response.requestOptions.uri}');
handler.next(response);
}
@override
void onError(DioException err, ErrorInterceptorHandler handler) {
debugPrint('✗ ${err.requestOptions.method} ${err.requestOptions.uri}: ${err.message}');
handler.next(err);
}
}
Retry interceptor
class RetryInterceptor extends Interceptor {
RetryInterceptor({this.retries = 3, this.retryDelays = const [1, 2, 4]});
final int retries;
final List<int> retryDelays; // In seconds
@override
void onError(DioException err, ErrorInterceptorHandler handler) async {
final options = err.requestOptions;
final attempt = options.extra['retry_count'] as int? ?? 0;
final isNetworkError = err.type == DioExceptionType.connectionTimeout ||
err.type == DioExceptionType.receiveTimeout ||
err.type == DioExceptionType.connectionError;
final isServerError = (err.response?.statusCode ?? 0) >= 500;
if ((isNetworkError || isServerError) && attempt < retries) {
options.extra['retry_count'] = attempt + 1;
final delay = retryDelays.elementAtOrNull(attempt) ?? retryDelays.last;
await Future.delayed(Duration(seconds: delay));
try {
final response = await Dio().fetch(options);
return handler.resolve(response);
} catch (e) {
// Fall through to handler.next
}
}
handler.next(err);
}
}
Composing interceptors
Dio buildDio(AuthService authService) {
final dio = Dio(BaseOptions(baseUrl: AppConfig.apiBaseUrl));
dio.interceptors.addAll([
AuthInterceptor(authService),
if (kDebugMode) LoggingInterceptor(),
RetryInterceptor(retries: 3),
]);
return dio;
}
Type-safe API with Retrofit
@RestApi()
abstract class OrderApi {
factory OrderApi(Dio dio, {String baseUrl}) = _OrderApi;
@GET('/orders')
Future<List<Order>> getOrders(
@Query('status') String? status,
@Query('page') int page,
@Query('limit') int limit,
);
@GET('/orders/{id}')
Future<Order> getOrder(@Path('id') String id);
@POST('/orders')
Future<Order> createOrder(@Body() CreateOrderRequest request);
@PATCH('/orders/{id}')
Future<Order> updateOrder(
@Path('id') String id,
@Body() UpdateOrderRequest request,
);
@DELETE('/orders/{id}')
Future<void> deleteOrder(@Path('id') String id);
@POST('/orders/{id}/items')
@MultiPart()
Future<OrderItem> uploadAttachment(
@Path('id') String orderId,
@Part() File file,
@Part() String description,
);
}
flutter pub run build_runner build --delete-conflicting-outputs
Error handling
Future<Order> getOrder(String id) async {
try {
return await _api.getOrder(id);
} on DioException catch (e) {
throw switch (e.type) {
DioExceptionType.connectionTimeout ||
DioExceptionType.receiveTimeout ||
DioExceptionType.connectionError => const NetworkError(),
DioExceptionType.badResponse => switch (e.response?.statusCode) {
404 => NotFoundError('Order $id'),
401 || 403 => const UnauthorizedError(),
422 => ValidationError(e.response?.data),
_ => ServerError(e.response?.statusCode),
},
_ => UnknownError(e),
};
}
}
Cancellation
final cancelToken = CancelToken();
try {
final response = await dio.get('/search',
queryParameters: {'q': query},
cancelToken: cancelToken,
);
} on DioException catch (e) {
if (CancelToken.isCancel(e)) {
debugPrint('Request cancelled');
} else {
rethrow;
}
}
// Cancel (e.g., when the search term changes)
cancelToken.cancel();
Common pitfalls
Not testing interceptors. Interceptors are easy to get wrong (infinite retry loops, double token refresh). Test them with a mock Dio:
final dioAdapter = DioAdapter(dio: Dio());
dioAdapter.onGet('/orders', (server) => server.reply(401, null));
// Assert that the auth interceptor refreshes and retries
Creating a new Dio instance for retry. In retry interceptors, Dio().fetch(options) creates a new Dio without your interceptors. Attach the retry Dio to the original instance or use the same Dio for retry.
Not setting timeouts. Without timeouts, a stalled request hangs indefinitely. Always set connectTimeout and receiveTimeout.
Sign in to like, dislike, or report.