Back to Blog
Flutter Development

Flutter REST API Integration with Dio: Networking Done Right

Production patterns for Flutter REST API integration using Dio — interceptors, retries, auth refresh, error handling, and offline-first design for mobile apps.

D
Don Wilson
Mobile Developer
April 15, 2026
12 min read
Flutter REST API Integration with Dio: Networking Done Right

Networking is where Flutter mobile apps go to die. Slow connections, expired tokens, server hiccups, and mid-flight cancellations all conspire against you. Dio is the package we reach for in every production Flutter app — it gives you interceptors, retries, cancellation, and a clean API on top of HttpClient.

Why Dio Over http

  • Interceptors for cross-cutting concerns (auth, logging, retry)
  • Built-in cancellation tokens
  • FormData and multipart uploads out of the box
  • Connection pooling and HTTP/2 via native_dio_adapter
  • First-class TypeScript-style code generation with retrofit

Setting Up the Client

final dio = Dio(BaseOptions(
  baseUrl: 'https://api.example.com',
  connectTimeout: const Duration(seconds: 10),
  receiveTimeout: const Duration(seconds: 20),
  headers: {'Accept': 'application/json'},
));

Auth Interceptor with Refresh

class AuthInterceptor extends Interceptor {
  final TokenStore store;
  final Dio refreshDio;

  AuthInterceptor(this.store, this.refreshDio);

  @override
  void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
    final token = store.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 && !err.requestOptions.extra.containsKey('retried')) {
      final newToken = await _refresh();
      if (newToken != null) {
        store.accessToken = newToken;
        final opts = err.requestOptions
          ..headers['Authorization'] = 'Bearer $newToken'
          ..extra['retried'] = true;
        final cloned = await refreshDio.fetch(opts);
        return handler.resolve(cloned);
      }
    }
    handler.next(err);
  }

  Future<String?> _refresh() async {
    try {
      final res = await refreshDio.post('/auth/refresh',
          data: {'refresh_token': store.refreshToken});
      return res.data['access_token'] as String;
    } catch (_) {
      return null;
    }
  }
}

Retry on Transient Failures

Use dio_smart_retry for exponential backoff on 5xx and connection errors:

dio.interceptors.add(RetryInterceptor(
  dio: dio,
  retries: 3,
  retryDelays: const [
    Duration(milliseconds: 200),
    Duration(seconds: 1),
    Duration(seconds: 4),
  ],
));

Typed Responses with Retrofit

@RestApi()
abstract class UserApi {
  factory UserApi(Dio dio) = _UserApi;

  @GET('/users/{id}')
  Future<User> getUser(@Path('id') String id);

  @POST('/users')
  Future<User> createUser(@Body() CreateUserDto body);
}

Run build_runner and you get a fully typed client with zero boilerplate.

Error Handling Strategy

Map every DioException into a domain error your UI can render:

sealed class AppError {}
class NetworkError extends AppError {}
class TimeoutError extends AppError {}
class UnauthorizedError extends AppError {}
class ServerError extends AppError { final int code; ServerError(this.code); }
class ValidationError extends AppError { final Map<String, String> fields; ValidationError(this.fields); }

AppError mapDioError(DioException e) {
  if (e.type == DioExceptionType.connectionTimeout ||
      e.type == DioExceptionType.receiveTimeout) return TimeoutError();
  if (e.type == DioExceptionType.connectionError) return NetworkError();
  final code = e.response?.statusCode ?? 0;
  if (code == 401) return UnauthorizedError();
  if (code == 422) return ValidationError(_parseFieldErrors(e.response!.data));
  return ServerError(code);
}

Cancellation

Cancel in-flight requests when the user navigates away to save battery and bandwidth:

final cancelToken = CancelToken();
final future = dio.get('/feed', cancelToken: cancelToken);

@override
void dispose() {
  cancelToken.cancel('screen disposed');
  super.dispose();
}

Offline-First with Local Cache

Combine Dio with dio_cache_interceptor backed by Hive or Isar:

final cacheStore = HiveCacheStore('cache');
dio.interceptors.add(DioCacheInterceptor(
  options: CacheOptions(
    store: cacheStore,
    policy: CachePolicy.refresh,
    maxStale: const Duration(days: 7),
    hitCacheOnErrorExcept: [401, 403],
  ),
));

Logging

Use pretty_dio_logger in debug only, never in release. Strip auth headers and sensitive bodies from logs.

Testing

final adapter = DioAdapter(dio: dio);
adapter.onGet('/users/42', (server) => server.reply(200, {
  'id': '42', 'name': 'Ada',
}));

final user = await UserApi(dio).getUser('42');
expect(user.name, 'Ada');

Common Pitfalls

  • Storing tokens in plaintext SharedPreferences — use flutter_secure_storage
  • Logging full URLs with query parameters that contain auth tokens
  • Skipping certificate pinning in security-sensitive apps
  • Refreshing the access token in N parallel requests — coalesce with a single in-flight refresh future

Conclusion

Dio takes the rough edges off Flutter networking. Build one client, layer interceptors for auth, retry, cache, and logging, and generate typed APIs with Retrofit. Wrap every error in a domain type your UI can handle. Done well, networking becomes a solved problem and you ship features instead of fighting the network.

Tags

FlutterREST APIDioNetworkingMobile App Development

Share this article