Networking
Dio HTTP client and API integration patterns for Flutter applications
You are an expert in HTTP networking and API integration for building cross-platform apps with Flutter. ## Key Points - **Centralize Dio configuration** in a single `ApiClient` class so interceptors, timeouts, and headers are consistent. - **Map `DioException` to domain exceptions** in the repository layer so the UI never depends on Dio directly. - **Use interceptors for cross-cutting concerns** (auth, logging, retry) rather than sprinkling logic across repositories. - **Cancel stale requests** in search-as-you-type and pagination scenarios to avoid race conditions. - **Freeze model classes** with `freezed` or use `json_serializable` for type-safe JSON parsing. - **Not handling `DioExceptionType.cancel`**: Cancelled requests throw exceptions. Catch them so the UI does not show an error for intentional cancellations. - **Infinite retry loops**: Always cap retries and use exponential backoff. Retrying 401s without refreshing the token causes loops. - **Leaking tokens in logs**: The default `LogInterceptor` prints headers including `Authorization`. Disable header logging in production. - **Forgetting `connectTimeout`**: Without a timeout, requests to unreachable servers hang indefinitely, freezing the UI if awaited on the main isolate. - **Parsing JSON on the main isolate**: For large responses, use `compute()` or `Isolate.run()` to parse JSON off the main thread.
skilldb get flutter-skills/NetworkingFull skill: 270 linesNetworking — Flutter
You are an expert in HTTP networking and API integration for building cross-platform apps with Flutter.
Core Philosophy
The networking layer is a boundary between your app and the outside world, and its design should reflect that boundary role. Dio provides the transport mechanism (HTTP requests, response handling, interceptors), but the networking layer's responsibility extends beyond transport: it must translate between HTTP primitives (status codes, JSON bytes, headers) and your domain types (models, typed errors, results). When this translation happens cleanly at the boundary, no code above it ever sees a DioException or a raw JSON map -- it works entirely with typed Dart objects.
Interceptors are the mechanism for cross-cutting concerns. Authentication (attaching and refreshing tokens), logging (recording request/response data for debugging), retry logic (retrying failed requests with backoff), and error mapping all belong in interceptors, not in individual repository methods. When these concerns are implemented once as interceptors and added to the Dio instance, every request benefits from them automatically, and adding a new API endpoint requires zero boilerplate for auth, logging, or retry.
The repository layer should own error translation. Dio throws DioException for every failure mode (timeout, connection error, bad response, cancellation), but your UI should never handle DioException directly. The repository catches DioException and translates it into domain-specific exception types (NetworkException, ApiException, AuthException) that the UI can handle meaningfully -- showing "No internet connection" versus "Server error" versus "Session expired, please log in again."
Anti-Patterns
-
Not handling DioExceptionType.cancel: When a search request is cancelled because the user typed another character, Dio throws a
DioExceptionwith typecancel. If the repository does not catch this specifically, the UI shows an error message for what was actually an intentional cancellation. Always handle cancellation separately from real errors. -
Retrying requests infinitely without backoff or limits: A retry interceptor that retries every failed request without a maximum attempt count or exponential backoff can overwhelm the server with requests when it is already struggling, and it keeps the user waiting indefinitely. Always cap retries (3 is a sensible maximum) and use exponential backoff.
-
Leaking authentication tokens in logs: The default
LogInterceptorprints all headers, includingAuthorization: Bearer <token>. In production, this writes tokens to log files that may be accessible to crash reporting services, analytics tools, or device logs. Disable header logging or filter sensitive headers in production builds. -
Forgetting connectTimeout, causing indefinite hangs: Without a
connectTimeout, a request to an unreachable server hangs until the operating system's TCP timeout (often 2+ minutes). Setting a 10-15 second connect timeout ensures the user sees an error quickly rather than staring at a loading spinner. -
Parsing large JSON responses on the main isolate: JSON parsing for a 2MB response blocks the main isolate for hundreds of milliseconds, causing visible UI jank. Use
compute()orIsolate.run()to parse large JSON payloads on a background isolate, keeping the UI thread responsive.
Overview
Dio is the most widely used HTTP client for Flutter, offering interceptors, request cancellation, form data handling, and file downloads. This skill covers structuring API layers, handling errors, authentication token management, and caching strategies.
Core Concepts
Dio Setup and Configuration
class ApiClient {
ApiClient({required String baseUrl, required TokenStore tokenStore})
: _dio = Dio(
BaseOptions(
baseUrl: baseUrl,
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 15),
headers: {'Accept': 'application/json'},
),
) {
_dio.interceptors.addAll([
AuthInterceptor(tokenStore),
LogInterceptor(requestBody: true, responseBody: true),
RetryInterceptor(dio: _dio),
]);
}
final Dio _dio;
Future<Response<T>> get<T>(String path, {Map<String, dynamic>? queryParameters}) {
return _dio.get<T>(path, queryParameters: queryParameters);
}
Future<Response<T>> post<T>(String path, {Object? data}) {
return _dio.post<T>(path, data: data);
}
Future<Response<T>> put<T>(String path, {Object? data}) {
return _dio.put<T>(path, data: data);
}
Future<Response<T>> delete<T>(String path) {
return _dio.delete<T>(path);
}
}
Interceptors
class AuthInterceptor extends Interceptor {
AuthInterceptor(this._tokenStore);
final TokenStore _tokenStore;
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
final token = _tokenStore.accessToken;
if (token != null) {
options.headers['Authorization'] = 'Bearer $token';
}
handler.next(options);
}
@override
Future<void> onError(DioException err, ErrorInterceptorHandler handler) async {
if (err.response?.statusCode == 401) {
try {
final newToken = await _tokenStore.refreshAccessToken();
err.requestOptions.headers['Authorization'] = 'Bearer $newToken';
final retryResponse = await Dio().fetch(err.requestOptions);
handler.resolve(retryResponse);
return;
} catch (_) {
_tokenStore.clear();
}
}
handler.next(err);
}
}
Repository Pattern
class ProductRepository {
ProductRepository(this._api);
final ApiClient _api;
Future<List<Product>> getProducts({int page = 1, int limit = 20}) async {
try {
final response = await _api.get<Map<String, dynamic>>(
'/products',
queryParameters: {'page': page, 'limit': limit},
);
final items = response.data!['items'] as List;
return items.map((json) => Product.fromJson(json as Map<String, dynamic>)).toList();
} on DioException catch (e) {
throw _mapException(e);
}
}
Future<Product> getProduct(String id) async {
try {
final response = await _api.get<Map<String, dynamic>>('/products/$id');
return Product.fromJson(response.data!);
} on DioException catch (e) {
throw _mapException(e);
}
}
AppException _mapException(DioException e) {
return switch (e.type) {
DioExceptionType.connectionTimeout ||
DioExceptionType.receiveTimeout => const NetworkException('Request timed out'),
DioExceptionType.badResponse => ApiException(
e.response?.statusCode ?? 0,
e.response?.data?['message'] as String? ?? 'Unknown error',
),
DioExceptionType.cancel => const NetworkException('Request cancelled'),
_ => const NetworkException('No internet connection'),
};
}
}
Implementation Patterns
Cancellation with CancelToken
class SearchRepository {
SearchRepository(this._api);
final ApiClient _api;
CancelToken? _cancelToken;
Future<List<SearchResult>> search(String query) async {
_cancelToken?.cancel(); // cancel previous request
_cancelToken = CancelToken();
final response = await _api.get<Map<String, dynamic>>(
'/search',
queryParameters: {'q': query},
);
// parse response...
}
}
File Upload with Progress
Future<String> uploadImage(File file) async {
final formData = FormData.fromMap({
'file': await MultipartFile.fromFile(
file.path,
filename: file.path.split('/').last,
),
});
final response = await _dio.post<Map<String, dynamic>>(
'/upload',
data: formData,
onSendProgress: (sent, total) {
final progress = sent / total;
debugPrint('Upload progress: ${(progress * 100).toStringAsFixed(0)}%');
},
);
return response.data!['url'] as String;
}
Retry Interceptor
class RetryInterceptor extends Interceptor {
RetryInterceptor({required this.dio, this.maxRetries = 3});
final Dio dio;
final int maxRetries;
@override
Future<void> onError(DioException err, ErrorInterceptorHandler handler) async {
final statusCode = err.response?.statusCode;
if (statusCode != null && statusCode >= 500 && statusCode < 600) {
final retryCount = (err.requestOptions.extra['retryCount'] as int?) ?? 0;
if (retryCount < maxRetries) {
err.requestOptions.extra['retryCount'] = retryCount + 1;
await Future.delayed(Duration(seconds: 1 << retryCount)); // exponential backoff
try {
final response = await dio.fetch(err.requestOptions);
handler.resolve(response);
return;
} catch (_) {}
}
}
handler.next(err);
}
}
Integrating with Riverpod
@riverpod
ApiClient apiClient(Ref ref) {
final tokenStore = ref.watch(tokenStoreProvider);
return ApiClient(
baseUrl: 'https://api.example.com/v1',
tokenStore: tokenStore,
);
}
@riverpod
ProductRepository productRepository(Ref ref) {
return ProductRepository(ref.watch(apiClientProvider));
}
@riverpod
Future<List<Product>> products(Ref ref) {
return ref.watch(productRepositoryProvider).getProducts();
}
Best Practices
- Centralize Dio configuration in a single
ApiClientclass so interceptors, timeouts, and headers are consistent. - Map
DioExceptionto domain exceptions in the repository layer so the UI never depends on Dio directly. - Use interceptors for cross-cutting concerns (auth, logging, retry) rather than sprinkling logic across repositories.
- Cancel stale requests in search-as-you-type and pagination scenarios to avoid race conditions.
- Freeze model classes with
freezedor usejson_serializablefor type-safe JSON parsing.
Common Pitfalls
- Not handling
DioExceptionType.cancel: Cancelled requests throw exceptions. Catch them so the UI does not show an error for intentional cancellations. - Infinite retry loops: Always cap retries and use exponential backoff. Retrying 401s without refreshing the token causes loops.
- Leaking tokens in logs: The default
LogInterceptorprints headers includingAuthorization. Disable header logging in production. - Forgetting
connectTimeout: Without a timeout, requests to unreachable servers hang indefinitely, freezing the UI if awaited on the main isolate. - Parsing JSON on the main isolate: For large responses, use
compute()orIsolate.run()to parse JSON off the main thread.
Install this skill directly: skilldb add flutter-skills
Related Skills
Animations
Implicit, explicit, and hero animation patterns for polished Flutter UIs
Local Storage
Hive, SharedPreferences, and Drift patterns for local data persistence in Flutter
Navigation
GoRouter navigation patterns for declarative, deep-linkable Flutter routing
Platform Channels
Platform channels for bridging Flutter with native Android (Kotlin) and iOS (Swift) code
State Management
Riverpod and Bloc state management patterns for scalable Flutter applications
Testing
Widget testing, unit testing, and integration testing patterns for Flutter apps