Skip to main content
Technology & EngineeringFlutter270 lines

Networking

Dio HTTP client and API integration patterns for Flutter applications

Quick Summary16 lines
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 lines
Paste into your CLAUDE.md or agent config

Networking — 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 DioException with type cancel. 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 LogInterceptor prints all headers, including Authorization: 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() or Isolate.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 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.

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

Install this skill directly: skilldb add flutter-skills

Get CLI access →