Skip to content
ExponaBLOG
FLUTTER · DAY 036 / 100

Networking with Dio: Flutter best practices explained

HTTP clients for production apps.

Expona Admin9 min read

The challenge of HTTP in production Flutter apps

Most Flutter developers start with the raw http package, fire requests straight from widgets, and wonder why their app crashes when a network timeout occurs. Networking with Dio: Flutter best practices explained means understanding that production apps need more than just a client—they need a robust architecture that handles authentication, error recovery, logging, and request lifecycle management. We've built dozens of e-commerce and SaaS apps where poor HTTP setup caused cascading failures in production, so we're sharing what actually works at scale.

Base options — centralizing configuration

Every production app makes dozens of HTTP requests. Repeating timeouts, base URLs, and default headers across every call is both error-prone and painful to maintain. The first step is to configure Dio once with sensible defaults and reuse it everywhere.

Create a dedicated service that initializes Dio with your base configuration:

dart
import 'package:dio/dio.dart';

class ApiClient {
  late final Dio _dio;

  ApiClient({String? baseUrl, Duration? timeout}) {
    _dio = Dio(
      BaseOptions(
        baseUrl: baseUrl ?? 'https://api.example.com',
        connectTimeout: timeout ?? const Duration(seconds: 15),
        receiveTimeout: timeout ?? const Duration(seconds: 15),
        sendTimeout: timeout ?? const Duration(seconds: 15),
        headers: {
          'Content-Type': 'application/json',
          'Accept': 'application/json',
        },
      ),
    );
  }

  Dio get client => _dio;
}

Why this matters: timeouts prevent your app from hanging indefinitely on slow networks. We've seen users on 3G connections get stuck for minutes because requests never completed. Setting all three timeout values—connect, send, and receive—gives you control over the full request lifecycle. The base URL centralization means if you change environments (dev, staging, prod), you change it in one place, not scattered throughout repositories.

In your service layer, inject this client and use it consistently:

dart
class UserRepository {
  final ApiClient _apiClient;

  UserRepository(this._apiClient);

  Future fetchUser(String id) async {
    final response = await _apiClient.client.get('/users/$id');
    return User.fromJson(response.data);
  }
}

Interceptors — handling auth, logging, and transformation

Interceptors let you intercept requests before they go out and responses before your code handles them. This is where you add authentication tokens, log requests for debugging, and transform errors into domain-specific exceptions.

Start with an authentication interceptor that automatically adds your JWT token:

dart
class AuthInterceptor extends Interceptor {
  final TokenStorage _tokenStorage;

  AuthInterceptor(this._tokenStorage);

  @override
  Future onRequest(
    RequestOptions options,
    RequestInterceptorHandler handler,
  ) async {
    final token = await _tokenStorage.getToken();
    if (token != null) {
      options.headers['Authorization'] = 'Bearer $token';
    }
    return handler.next(options);
  }

  @override
  Future onError(
    DioException err,
    ErrorInterceptorHandler handler,
  ) async {
    if (err.response?.statusCode == 401) {
      // Token expired—refresh and retry
      final newToken = await _tokenStorage.refreshToken();
      if (newToken != null) {
        err.requestOptions.headers['Authorization'] = 'Bearer $newToken';
        return handler.resolve(await _dio.request(err.requestOptions.path));
      }
    }
    return handler.next(err);
  }
}

Add a logging interceptor for debugging:

dart
class LoggingInterceptor extends Interceptor {
  @override
  Future onRequest(
    RequestOptions options,
    RequestInterceptorHandler handler,
  ) {
    print('→ ${options.method} ${options.path}');
    if (options.data != null) {
      print('  Data: ${options.data}');
    }
    return handler.next(options);
  }

  @override
  Future onResponse(
    Response response,
    ResponseInterceptorHandler handler,
  ) {
    print('← ${response.statusCode} ${response.requestOptions.path}');
    return handler.next(response);
  }

  @override
  Future onError(
    DioException err,
    ErrorInterceptorHandler handler,
  ) {
    print('✗ ERROR: ${err.message}');
    return handler.next(err);
  }
}

Register both in your Dio setup:

dart
class ApiClient {
  late final Dio _dio;

  ApiClient({
    String? baseUrl,
    Duration? timeout,
    required TokenStorage tokenStorage,
  }) {
    _dio = Dio(
      BaseOptions(
        baseUrl: baseUrl ?? 'https://api.example.com',
        connectTimeout: timeout ?? const Duration(seconds: 15),
        receiveTimeout: timeout ?? const Duration(seconds: 15),
        sendTimeout: timeout ?? const Duration(seconds: 15),
      ),
    );

    _dio.interceptors.add(AuthInterceptor(tokenStorage));
    _dio.interceptors.add(LoggingInterceptor());
  }

  Dio get client => _dio;
}

In production, turn off logging interceptors or write to a file instead of print. We've debugged production crashes where logs revealed that a request was sent with stale data, so structured logging saves hours of investigation.

Cancel tokens — cleaning up when screens close

When a user navigates away from a screen, in-flight requests should be cancelled. Otherwise, your app might update widgets that no longer exist, causing errors or wasting battery on unnecessary work.

Create a request manager that ties cancellation to the widget lifecycle:

dart
class RequestManager {
  final Map _tokens = {};

  CancelToken createToken(String key) {
    _tokens[key] = CancelToken();
    return _tokens[key]!;
  }

  void cancelAll(String screenKey) {
    if (_tokens.containsKey(screenKey)) {
      _tokens[screenKey]!.cancel();
      _tokens.remove(screenKey);
    }
  }

  void cancelRequest(String requestKey) {
    if (_tokens.containsKey(requestKey)) {
      _tokens[requestKey]!.cancel();
      _tokens.remove(requestKey);
    }
  }
}

Use it in your repository with a Riverpod provider or your state manager:

dart
class UserRepository {
  final ApiClient _apiClient;
  final RequestManager _requestManager;

  UserRepository(this._apiClient, this._requestManager);

  Future> fetchUsers({required String screenKey}) async {
    final cancelToken = _requestManager.createToken(screenKey);
    try {
      final response = await _apiClient.client.get(
        '/users',
        cancelToken: cancelToken,
      );
      return (response.data as List)
          .map((u) => User.fromJson(u))
          .toList();
    } on DioException catch (e) {
      if (e.type == DioExceptionType.cancel) {
        print('Request cancelled');
      }
      rethrow;
    }
  }
}

In your widget, cancel requests on dispose:

dart
class UserListScreen extends ConsumerStatefulWidget {
  const UserListScreen({Key? key}) : super(key: key);

  @override
  ConsumerState createState() => _UserListScreenState();
}

class _UserListScreenState extends ConsumerState {
  static const String screenKey = 'user_list_screen';

  @override
  void dispose() {
    ref.read(requestManagerProvider).cancelAll(screenKey);
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return FutureBuilder(
      future: ref.read(userRepositoryProvider).fetchUsers(screenKey: screenKey),
      builder: (context, snapshot) {
        // Handle snapshot
      },
    );
  }
}

Error mapping — converting network errors into domain errors

DioException is a network-level concern. Your business logic should deal with domain-level errors: AuthenticationError, ValidationError, ServerError, and so on. Map between them consistently to keep your app architecture clean.

Define your domain errors:

dart
abstract class AppError implements Exception {
  final String message;
  AppError(this.message);
}

class AuthenticationError extends AppError {
  AuthenticationError(String message) : super(message);
}

class ValidationError extends AppError {
  final Map fieldErrors;
  ValidationError(String message, {this.fieldErrors = const {}})
      : super(message);
}

class ServerError extends AppError {
  final int? statusCode;
  ServerError(String message, {this.statusCode}) : super(message);
}

class NetworkError extends AppError {
  NetworkError(String message) : super(message);
}

class NotFoundError extends AppError {
  NotFoundError(String message) : super(message);
}

Create an extension on DioException that maps to your domain errors:

dart
extension DioExceptionMapper on DioException {
  AppError toAppError() {
    if (type == DioExceptionType.cancel) {
      return NetworkError('Request was cancelled');
    }
    if (type == DioExceptionType.connectionTimeout ||
        type == DioExceptionType.receiveTimeout ||
        type == DioExceptionType.sendTimeout) {
      return NetworkError('Network timeout. Please check your connection.');
    }
    if (type == DioExceptionType.connectionError) {
      return NetworkError('No internet connection');
    }

    final statusCode = response?.statusCode;
    final data = response?.data;

    switch (statusCode) {
      case 400:
        return ValidationError(
          _extractMessage(data) ?? 'Invalid request',
          fieldErrors: _extractFieldErrors(data),
        );
      case 401:
        return AuthenticationError(
          _extractMessage(data) ?? 'Authentication failed',
        );
      case 404:
        return NotFoundError(_extractMessage(data) ?? 'Resource not found');
      case 500:
      case 502:
      case 503:
        return ServerError(
          _extractMessage(data) ?? 'Server error',
          statusCode: statusCode,
        );
      default:
        return ServerError(
          _extractMessage(data) ?? 'An error occurred',
          statusCode: statusCode,
        );
    }
  }

  String? _extractMessage(dynamic data) {
    if (data is Map) {
      return data['message'] as String? ??
          data['error'] as String? ??
          data['detail'] as String?;
    }
    return null;
  }

  Map _extractFieldErrors(dynamic data) {
    if (data is Map && data['errors'] is Map) {
      return Map.from(data['errors']);
    }
    return {};
  }
}

Use it in your repositories:

dart
class UserRepository {
  final ApiClient _apiClient;

  UserRepository(this._apiClient);

  Future fetchUser(String id) async {
    try {
      final response = await _apiClient.client.get('/users/$id');
      return User.fromJson(response.data);
    } on DioException catch (e) {
      throw e.toAppError();
    }
  }
}

Now your BLoCs, ViewModels, or state managers catch AppError instead of DioException, and UI code can handle specific error types predictably.

Common mistakes

  • Ignoring timeouts: Leaving timeouts at Dio's defaults (30 seconds) means your app hangs on slow networks. Choose timeouts based on your API's typical response time plus buffer for poor connectivity.
  • Logging tokens in production: If you log auth headers verbatim, tokens end up in logs or crash reports. Always redact sensitive data or use conditional logging.
  • Creating a new Dio instance per request: Each Dio instance has its own connection pool. Create one global instance and reuse it. We've seen apps create hundreds of instances, exhausting system resources.
  • Not handling 401s in interceptors: If your token expires mid-request, the user should not see an AuthenticationError and be forced to log in again. Refresh the token silently and retry.
  • Forgetting to cancel requests on screen close: Leads to "setState called after dispose" errors and memory leaks. Use cancel tokens rigorously.
  • Mixing network and domain errors: If your UI sees DioException, it has to know about connection timeouts, HTTP status codes, and Dio internals. Keep network logic in repositories; surface domain errors to the UI.

Key takeaways

  • Centralize Dio configuration in one place. Use sensible timeouts and default headers to avoid repeating them everywhere.
  • Use interceptors for cross-cutting concerns: add auth tokens, log requests, and refresh expired credentials transparently.
  • Cancel requests when screens close with CancelToken to prevent race conditions and memory leaks.
  • Map DioException to domain-specific errors (AuthenticationError, ValidationError, ServerError) so your business logic stays clean.
  • Test error paths early. Most production bugs come from edge cases: network failures, token expiry, slow connections, and malformed responses.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles