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:
QueuedInterceptorserializes interceptor execution — the standard fix for a thundering herd of 401s each triggering their own token refresh.LogInterceptorlogs requests/responses; add it last so later interceptors’ changes are logged, and usedebugPrinton Flutter so output survivesflutter logs.- Errors are
DioExceptionwith atypeand a nullableresponse— distinguish “server said 500” from “network never connected” before showing a retry button. - Transformers parse the body. The default
BackgroundTransformerdecodes JSON in an isolate above 50 KB — a free jank fix for big responses. HttpClientAdapteris the seam between Dio and the platform:IOHttpClientAdapteron native (proxy support, certificate pinning viavalidateCertificate),BrowserHttpClientAdapteron web.dio_http2_adapteradds HTTP/2.CancelTokencancels in-flight requests — essential when a widget that owns the request gets disposed mid-flight.FormData+MultipartFilehandle 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.FlutterComputeparses JSON in a background isolate — big payloads off the UI thread without hand-rollingcompute().CallAdapterandTypedExtrasfor cross-cutting behavior (auth retries, feature flags) without touching each method.build.yamloptions:format_output,auto_cast_response,empty_request_body.
How they fit together
| Layer | Responsibility | You write | Generated/maintained |
|---|---|---|---|
Dio | Transport, timeouts, auth, retries, logging | Interceptors + options | — |
retrofit | Typed endpoints, param mapping, parsing | Annotated abstract class | _*Api implementation |
json_serializable / freezed | DTO ↔ model conversion | Model 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.