Skip to content
Blog

Networking in Flutter: Dio Interceptors and Retrofit Code Generation

A production-oriented tour of Flutter HTTP clients: Dio's interceptors, transformers and adapters, plus Retrofit-generated type-safe REST clients on top of Dio.

Published on • October 3, 2026

AI Assistant

The http package is fine for one-off GETs. Real apps need auth refresh, retries, timeouts, logging, file uploads and typed models — which is where dio (5.11.1, ~4.7M downloads) and retrofit (4.10.0) come in. Together they give you a configurable client and a generated, type-safe API layer on top of it.

Sources: dio on pub.dev, retrofit on pub.dev.

Dio: the configurable client

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

final response = await dio.get('/tasks', queryParameters: {'page': 1});

Dio’s real power is the middleware layer:

dio.interceptors.add(InterceptorsWrapper(
  onRequest: (options, handler) {
    options.headers['Authorization'] = 'Bearer ${_token()}';
    handler.next(options);
  },
  onError: (err, handler) async {
    if (err.response?.statusCode == 401) {
      await _refreshToken();          // then retry via handler.resolve/reject
    }
    handler.next(err);
  },
));

Highlights from the README worth knowing:

  • QueuedInterceptor serializes interceptor execution — the standard fix for a thundering herd of 401s each triggering their own token refresh.
  • LogInterceptor logs requests/responses; add it last so later interceptors’ changes are logged, and use debugPrint on Flutter so output survives flutter logs.
  • Errors are DioException with a type and a nullable response — distinguish “server said 500” from “network never connected” before showing a retry button.
  • Transformers parse the body. The default BackgroundTransformer decodes JSON in an isolate above 50 KB — a free jank fix for big responses.
  • HttpClientAdapter is the seam between Dio and the platform: IOHttpClientAdapter on native (proxy support, certificate pinning via validateCertificate), BrowserHttpClientAdapter on web. dio_http2_adapter adds HTTP/2.
  • CancelToken cancels in-flight requests — essential when a widget that owns the request gets disposed mid-flight.
  • FormData + MultipartFile handle uploads with progress callbacks (onSendProgress).

One sharp edge from the docs: never reuse a FormData/MultipartFile across requests — create fresh ones each time or you’ll hit Cannot finalize errors.

Retrofit: stop writing request plumbing

Dio gets bytes on the wire; Retrofit generates the typed surface from annotations:

@RestApi(baseUrl: 'https://api.example.com/v1/')
abstract class TaskApi {
  factory TaskApi(Dio dio, {String? baseUrl}) = _TaskApi;

  @GET('/tasks')
  Future<List<Task>> getTasks(@Query('page') int page);

  @POST('/tasks')
  Future<Task> createTask(@Body() Task task);

  @GET('/tasks/{id}')
  Future<Task> getTask(@Path('id') String id);
}

Run the generator and _TaskApi appears:

dart run build_runner build --delete-conflicting-outputs

The dependency set is small and pinned: retrofit ^4.10.0, retrofit_generator ^10.0.1, build_runner ^2.6.0, json_serializable ^6.10.0, all on top of dio ^5.9.0. Annotation coverage includes @Path/@Query/@Queries/@Body/@Header/@Headers, @MultiPart/@FormUrlEncoded/@Part, and — notably for AI-era apps — @DioResponseType(ResponseType.stream) for SSE streaming responses.

Advanced features that earn their keep in production:

  • HttpResponse<T> when you need status code and headers alongside the body.
  • Parser.FlutterCompute parses JSON in a background isolate — big payloads off the UI thread without hand-rolling compute().
  • CallAdapter and TypedExtras for cross-cutting behavior (auth retries, feature flags) without touching each method.
  • build.yaml options: format_output, auto_cast_response, empty_request_body.

How they fit together

LayerResponsibilityYou writeGenerated/maintained
DioTransport, timeouts, auth, retries, loggingInterceptors + options—
retrofitTyped endpoints, param mapping, parsingAnnotated abstract class_*Api implementation
json_serializable / freezedDTO ↔ model conversionModel classes*.g.dart

A clean split is: one Dio instance configured app-wide, one generated API class per backend service, and repositories that own caching/offline logic above them. Retrofit’s factory constructor takes the Dio you pass in, so tests can inject a client with a mock adapter.

When to skip both

For a single GET, http or package:dio alone is less machinery. If your backend is GraphQL, you’ll want graphql_flutter instead. And on web, remember CORS applies — Dio can’t bypass it, and proxy configuration is native-only.

But for a typical REST-backed Flutter app, Dio + Retrofit is the boring, well-trodden path: interceptors for the cross-cutting concerns, code generation for the repetitive ones, and zero hand-written JSON plumbing to mistype.