← 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.

HTTP networking in Flutter with Dio — ANN Tech