Once an app talks to more than one endpoint, raw networking starts to repeat itself: the same base URL, the same auth header, the same timeout and error plumbing in every call. Dio is the package most Flutter teams reach for to stop that repetition. It gives you one configurable client, interceptors that run on every request, real timeouts, automatic JSON decoding, and a typed DioException you can branch on. This tutorial walks through a configured Dio instance, GET and POST, auth and logging interceptors, timeouts and a simple retry, and clean error handling, with paste-ready code.

The Complete Flutter Guide: Build Android, iOS and Web apps
Go from scratch to building industry-standard apps with Riverpod, Firebase, animations, REST APIs, and more.
Enrol nowEvery snippet is correct against current stable Flutter. dio is a package, so add it first — drop dio: ^5.7.0 under dependencies in pubspec.yaml, run flutter pub get, and import it with import 'package:dio/dio.dart'; wherever you make requests.
I post short, practical Flutter snippets and behind-the-scenes work on networking layers there most days.
I share longer write-ups on app architecture and the trade-offs behind networking decisions on LinkedIn.
Why Dio
The built-in http package is perfect for a one-off request, but it is deliberately minimal. You decode JSON yourself, you re-attach the auth header on every call, there is no first-class timeout configuration, and an error is just an exception with little structure. As an app grows, that boilerplate spreads across every service.
Dio bundles the cross-cutting concerns into one client. A single instance carries your base URL and default headers, interceptors run automatically for every request, timeouts are configuration rather than manual Future wrangling, responses arrive already decoded, and failures come back as a typed DioException you can switch on. You configure once and the whole app benefits — which is exactly why so many production codebases standardise on it.
Create a configured Dio instance (BaseOptions)
Create one Dio instance and reuse it. BaseOptions sets the defaults that apply to every request: the base URL, headers, and timeouts. Putting it behind a single getter keeps networking consistent.
import 'package:dio/dio.dart';
final dio = Dio(
BaseOptions(
baseUrl: 'https://jsonplaceholder.typicode.com',
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 10),
sendTimeout: const Duration(seconds: 10),
headers: {'Accept': 'application/json'},
responseType: ResponseType.json,
),
);
Because baseUrl is set here, call sites only pass the path: dio.get('/posts'). The timeouts make slow requests fail fast instead of hanging the UI, and responseType: ResponseType.json tells Dio to parse the body for you.
GET and POST
With the instance configured, requests are short. Dio decodes the JSON automatically, so response.data is already a Map or List rather than a raw string.
// GET with query parameters
final res = await dio.get(
'/posts',
queryParameters: {'userId': 1},
);
final List posts = res.data as List; // already decoded
// POST with a JSON body
final created = await dio.post(
'/posts',
data: {'title': 'Hello', 'body': 'From Dio', 'userId': 1},
);
print(created.statusCode); // 201
Pass queryParameters for the query string and data for the request body — Dio serialises a Map to JSON and sets the content type. You almost never touch the raw response string. To turn res.data into typed models, decode it the same way you would any JSON; that step is covered in the parse JSON tutorial.

Build a production networking layer
The Complete Flutter Guide builds a Dio client with interceptors, models, and error handling inside real, shipped apps.
Enrol nowInterceptors for auth and logging
Interceptors are Dio's headline feature. They sit between your call and the network, running on every request, response, and error for that instance. An auth interceptor attaches the token in one place; a logging interceptor prints requests during development.
dio.interceptors.add(
InterceptorsWrapper(
onRequest: (options, handler) {
final token = readToken(); // from secure storage
if (token != null) {
options.headers['Authorization'] = 'Bearer $token';
}
handler.next(options); // continue
},
onError: (e, handler) {
// e.g. clear session on 401
if (e.response?.statusCode == 401) clearSession();
handler.next(e); // propagate
},
),
);
// Built-in request/response logging
dio.interceptors.add(LogInterceptor(requestBody: true, responseBody: true));
The rule with interceptors is to always call the handler: handler.next(...) to continue the chain, handler.resolve(...) to short-circuit with a response, or handler.reject(...) to fail. Forget the handler and the request hangs forever. With the auth interceptor in place, you never repeat the Authorization header at any call site again.
Timeouts and retries
The timeouts you set in BaseOptions cover the three phases of a request: connectTimeout for establishing the connection, sendTimeout for uploading the body, and receiveTimeout for downloading the response. When any is exceeded, Dio throws a DioException with a matching type instead of waiting indefinitely.
A basic retry is just a loop that re-issues the request on transient failures. Keep it small and bounded, and only retry idempotent calls such as GET.
Future<Response> getWithRetry(String path, {int retries = 2}) async {
for (var attempt = 0; ; attempt++) {
try {
return await dio.get(path);
} on DioException catch (e) {
final transient = e.type == DioExceptionType.connectionTimeout ||
e.type == DioExceptionType.receiveTimeout ||
e.type == DioExceptionType.connectionError;
if (!transient || attempt >= retries) rethrow;
await Future.delayed(Duration(milliseconds: 300 * (attempt + 1)));
}
}
}
For anything more advanced — exponential backoff, retry budgets, jitter — reach for the community dio_smart_retry interceptor rather than hand-rolling it. The loop above is enough for most apps.
Handle DioException
Every Dio failure is a DioException. It carries a type that tells you what went wrong and an optional response with the status code and body for server-side errors. Branch on the type to show the right message.
try {
final res = await dio.get('/posts');
return res.data;
} on DioException catch (e) {
switch (e.type) {
case DioExceptionType.connectionTimeout:
case DioExceptionType.receiveTimeout:
case DioExceptionType.sendTimeout:
throw 'The request timed out. Check your connection.';
case DioExceptionType.badResponse:
final code = e.response?.statusCode;
throw 'Server error ($code).';
case DioExceptionType.connectionError:
throw 'No internet connection.';
default:
throw 'Something went wrong. Please try again.';
}
}
Note that a 4xx or 5xx status arrives as a badResponse exception, not a successful response — Dio treats non-2xx codes as errors by default. Read e.response?.statusCode and e.response?.data to surface the server's message. Catching DioException in one place, often the repository layer, keeps your widgets free of networking detail.
Dio vs http
Both are good packages; they target different needs. The http package is small, has no transitive features, and is ideal when you make a handful of plain requests and want to keep dependencies lean — see the http requests tutorial for that lighter approach. Dio earns its place when you need interceptors, configurable timeouts, automatic JSON decoding, request cancellation, upload and download progress, or a structured error type. If you find yourself re-attaching headers and re-decoding JSON in every service, that is the signal to move to Dio.
Query parameters and a base URL
Once baseUrl lives in BaseOptions, every call site passes only the path — Dio joins the two for you. That keeps the host in exactly one place, so swapping a staging server for production is a one-line change rather than a search-and-replace across the codebase. For the query string, hand Dio a plain Map through queryParameters and let it encode the values; you never build ?a=1&b=2 by hand.
final dio = Dio(BaseOptions(baseUrl: 'https://api.example.com/v1'));
// Resolves to https://api.example.com/v1/search?q=flutter&page=2&sort=recent
final res = await dio.get(
'/search',
queryParameters: {
'q': 'flutter',
'page': 2,
'sort': 'recent',
},
);
final List results = res.data['items'] as List;
Note the leading slash on the path and the absence of a trailing slash on baseUrl — that pairing produces the cleanest join. Dio URL-encodes each query value, so spaces and symbols are handled safely. Numbers and booleans are stringified automatically, and a list value such as {'tag': ['a', 'b']} is expanded into repeated keys.
Upload files with FormData
To send a multipart upload — a profile photo, a document, anything binary — wrap the fields in FormData.fromMap and add the file with MultipartFile.fromFile. Pass the FormData as data and Dio sets the multipart/form-data content type and boundary for you; you do not write that header yourself.
Future<void> uploadAvatar(String filePath, int userId) async {
final formData = FormData.fromMap({
'userId': userId,
'caption': 'My new avatar',
'avatar': await MultipartFile.fromFile(
filePath,
filename: 'avatar.jpg',
),
});
final res = await dio.post('/uploads', data: formData);
print(res.statusCode); // 201
}
Mix ordinary fields and files in the same map — text values ride along as form fields beside the binary part. For multiple files, give the same key a list of MultipartFiles, or use MultipartFile.fromBytes when the data is already in memory rather than on disk. Pair this with a ProgressCallback via onSendProgress if you want an upload bar.
Cancel a request with CancelToken
If a user leaves a screen while a request is in flight, finishing that request wastes bandwidth and can fire callbacks on a disposed widget. A CancelToken lets you abort it. Create one token, pass it to the request, and call cancel when you no longer need the result — cancelling surfaces as a DioException of type cancel, which you treat as a non-error.
final _cancelToken = CancelToken();
Future<void> load() async {
try {
final res = await dio.get('/posts', cancelToken: _cancelToken);
// use res.data
} on DioException catch (e) {
if (CancelToken.isCancel(e) || e.type == DioExceptionType.cancel) {
return; // expected — the screen was closed, ignore quietly
}
rethrow; // a real failure
}
}
@override
void dispose() {
_cancelToken.cancel('Screen closed'); // abort any in-flight request
super.dispose();
}
Use CancelToken.isCancel(e) or check e.type == DioExceptionType.cancel so a cancellation never shows the user an error. Cancelling in dispose() is the common case in a StatefulWidget; note that one token cancels every request it was attached to, so use a fresh token per screen or per logical operation.
Refresh the token and retry
When an access token expires the server replies with 401. Rather than logging the user out, intercept that error, refresh the token, and replay the original request once. A QueuedInterceptorsWrapper is the right tool here: it serialises errors, so if several requests fail with 401 at the same moment they queue behind a single refresh instead of triggering a refresh each. The guard against an infinite loop is essential — flag the retried request so a second 401 falls straight through.
dio.interceptors.add(
QueuedInterceptorsWrapper(
onError: (e, handler) async {
final isAuthError = e.response?.statusCode == 401;
final alreadyRetried = e.requestOptions.extra['retried'] == true;
if (!isAuthError || alreadyRetried) {
return handler.next(e); // not recoverable here, propagate
}
try {
final newToken = await refreshToken(); // your refresh call
// Mark the request so it is only retried once.
final options = e.requestOptions
..extra['retried'] = true
..headers['Authorization'] = 'Bearer $newToken';
final response = await dio.fetch(options); // replay it
return handler.resolve(response); // success — return to caller
} on DioException catch (_) {
return handler.next(e); // refresh failed, surface the original 401
}
},
),
);
The flag stored in requestOptions.extra is what prevents a loop: if the refreshed token is also rejected, alreadyRetried is true and the interceptor stops retrying. dio.fetch(requestOptions) re-issues the exact original request — same method, path, body, and query — with the new header attached. Keep your refresh call on a separate Dio instance (or a path the interceptor ignores) so refreshing cannot recurse into itself.
Track download progress
For files you want to save to disk — a PDF, an update bundle — use dio.download. The onReceiveProgress callback fires as bytes arrive, giving you the received count and the total so you can show a percentage. The total is -1 when the server omits a Content-Length header, so guard against that before dividing.
await dio.download(
'https://example.com/report.pdf',
savePath, // e.g. '${dir.path}/report.pdf'
onReceiveProgress: (received, total) {
if (total != -1) {
final percent = (received / total * 100).toStringAsFixed(0);
print('Downloaded $percent%');
}
},
);
Wire that percentage into a LinearProgressIndicator and you have a live download bar. A CancelToken works here too, so a user can abort a large download mid-flight.
Common mistakes
- Creating a new Dio in every class. Build one configured instance and reuse it, or interceptors and base options won't apply.
- Forgetting to call the handler in an interceptor. Always call
handler.next,resolve, orreject, or the request stalls. - Expecting a 404 in the response. Non-2xx codes throw a
badResponseDioException; read the status frome.response. - No timeouts. Without
connectTimeoutandreceiveTimeouta dead network hangs the request indefinitely. - Retrying non-idempotent calls. Only retry safe requests like GET; retrying a POST can create duplicates.
- Hard-coding the full URL on every call. Set
baseUrlonce inBaseOptionsand pass relative paths; repeating the host everywhere makes switching environments a painful search-and-replace. - Catching a generic
catch (e). CatchDioExceptionand inspect itsDioExceptionTypeso you can tell a timeout from abadResponsefrom a cancellation, instead of showing one vague message for everything. - Not cancelling in-flight requests on
dispose(). Without aCancelTokencancelled indispose(), a late response can callsetStateon a widget that no longer exists.
Frequently asked questions
What is Dio in Flutter?
Dio is a configurable HTTP client package for Dart and Flutter. It wraps networking in one instance and adds interceptors, timeouts, automatic JSON decoding, and a typed DioException that the built-in http package does not provide out of the box.
How do I add an auth token to every request with Dio?
Add an interceptor and set the header in onRequest: read your token, assign options.headers['Authorization'] = 'Bearer $token', then call handler.next(options). It runs for every request through that instance, so you never repeat the header.
How do I handle errors and timeouts in Dio?
Catch DioException and switch on e.type to tell a timeout apart from a badResponse, reading e.response?.statusCode for the HTTP status. Set connectTimeout and receiveTimeout in BaseOptions so slow requests fail fast.
Should I use Dio or the http package?
Use http for a few simple requests with no extra dependencies. Choose Dio when you need interceptors, timeouts, retries, cancellation, or upload progress — one configured instance keeps networking consistent across a larger app.
Further reads
Keep going with the tutorials that pair with this guide:
- Flutter Development Guide 2026 — the full Flutter hub.
- Flutter HTTP Requests — the lighter http package.
- Flutter Parse JSON — decode Dio responses.
- Flutter REST API Integration — the full flow.
- Flutter FutureBuilder vs StreamBuilder — render the response.
- Flutter Handle API Errors — retries, recovery, and user feedback.
Sources: Dio package documentation — Dio, BaseOptions, Interceptors, and DioException (pub.dev/packages/dio). Verified against current stable Flutter.