Updated June 2026. Tested on Flutter 3.x and Dart 3. Part of the Techalyst Flutter series.

Most apps are a friendly face over an API. Flutter can do HTTP with the official http package, and for simple calls it is fine, but once you need base URLs, timeouts, automatic JSON, and a token on every request, the dio package saves a lot of boilerplate. This is how to use it without overcomplicating things.

A configured client

The first win with Dio is creating one configured instance and reusing it, instead of repeating the base URL and headers on every call:

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

Now every request goes through that client with the same settings, and you only ever pass the path.

GET and POST

Requests are short, and Dio decodes the JSON for you, so response.data is already a Map or List, not a raw string you have to parse:

// GET a list
final response = await dio.get('/products');
final products = (response.data as List)
    .map((json) => Product.fromJson(json))
    .toList();

// POST a body
await dio.post('/orders', data: {'productId': 3, 'quantity': 2});

Query strings go through queryParameters, which is cleaner than building the URL by hand:

final results = await dio.get('/search', queryParameters: {'q': 'phone', 'page': 1});

Handling errors properly

Network calls fail: no connection, a timeout, a 404, a 500. Dio throws a DioException, and it carries enough detail to react sensibly:

try {
  final response = await dio.get('/products');
  return (response.data as List).map(Product.fromJson).toList();
} on DioException catch (e) {
  if (e.type == DioExceptionType.connectionTimeout) {
    throw Exception('The server took too long to respond.');
  }
  if (e.response?.statusCode == 404) {
    throw Exception('Not found.');
  }
  throw Exception('Request failed: ${e.message}');
}

e.type tells you the kind of failure, like a timeout or a bad response, and e.response?.statusCode gives you the HTTP status when there was one. Mapping these to clear messages is what makes an app feel solid instead of silently broken.

Interceptors: the real reason to use Dio

Interceptors let you run code on every request or response in one place. The classic use is attaching an auth token to all calls without repeating yourself:

dio.interceptors.add(
  InterceptorsWrapper(
    onRequest: (options, handler) {
      final token = authStore.token;
      if (token != null) {
        options.headers['Authorization'] = 'Bearer $token';
      }
      return handler.next(options); // carry on
    },
    onError: (error, handler) {
      if (error.response?.statusCode == 401) {
        // token expired: refresh or sign the user out
      }
      return handler.next(error);
    },
  ),
);

This is the part that pays for the package. Auth headers, logging, and token refresh all live in one interceptor instead of being copy-pasted into every call.

Showing the data

A request returns a Future, so it slots straight into the loading patterns from async and FutureBuilder. Kick off the call once, hold the future, and let FutureBuilder render the waiting, error, and loaded states.

Wrapping up

For real apps, Dio earns its place. Configure one client with your base URL and timeouts, use the short get and post methods with queryParameters and data, and let it decode JSON for you. Catch DioException and map the type and status code to clear messages, and put cross-cutting concerns like auth tokens into an interceptor so they live in exactly one spot. Pair the future it returns with FutureBuilder and your networking layer stays small and predictable.