The dio package in Flutter: requests, errors, interceptors, downloading and uploading files

Egor Dolgov · 12.11.2026
App
Server
→ GET /posts/1
address · token · timeout
→ GET /posts/2
address · token · timeout
→ GET /posts/3
address · token · timeout
→ GET /posts/4
address · token · timeout
Show me step by step
Subtitles
First, pick your system — the commands will match it.

dio is a package for HTTP requests in Dart and Flutter, just like http from Lesson 7 . It can do more: shared options for all requests, interceptors, downloading and uploading files with progress. Both packages can cancel requests. In this article we look at what that gives you and how to use it.

Contents:

  1. When to pick dio and when http is enough
  2. Adding the package and running the examples
  3. The first request
  4. Errors: a 404 is already an exception
  5. Shared options: baseUrl and timeouts
  6. POST: sending JSON
  7. Interceptors: code before every request
  8. Cancelling a request
  9. Downloading a file with progress
  10. Uploading files to a server
  11. dio in a Flutter app
  12. http vs dio, point by point
  13. Common mistakes
  14. Summary

1. When to pick dio and when http is enough

The task: an app sends dozens of requests to one server. Each of them needs the same server address, an authorization token and a time limit, and you want to handle errors in one place. The http package has no shared options or interceptors — you would have to write them yourself. dio has them built in:

  • shared options — the server address, timeouts and headers are set once for all requests;
  • interceptors — code that runs before every request and after every response;
  • request cancellation — for example, when the user has left the screen ( http has AbortableRequest for this);
  • downloading and uploading files with progress reports.

If you only have a couple of requests and need none of this, http from Lesson 7 is enough. The official Flutter guides use http too: for example, Fetch data from the internet . The Flutter documentation has no separate guide for dio.

dio is published by the CFUG team — the flutter.cn publisher on pub.dev; the source is on GitHub .

2. Adding the package and running the examples

In a Flutter project, run this in the project folder:

flutter pub add dio

pubspec.yaml gets the line dio: ^5.11.1 . In code, the package is imported like this:

import 'package:dio/dio.dart';

The examples in sections 3–10 are short console programs: that way you see the result of every request. They don’t need a separate Flutter project, and you already have the dart command — the Flutter SDK includes the full Dart SDK . Create a console project and add dio:

dart create dio_demo
cd dio_demo
dart pub add dio

Each example is a separate program: paste it into bin/dio_demo.dart in place of what is there and run it with dart run . The example in a Flutter app is in section 11.

3. The first request

The task: get post number 1 from a server. We request it from the JSONPlaceholder test server, as in Lesson 7:

import 'package:dio/dio.dart';

Future<void> main() async {
  final dio = Dio();
  final response = await dio.get(
    'https://jsonplaceholder.typicode.com/posts/1',
  );
  print(response.statusCode);
  print(response.data.runtimeType);
  print(response.data['title']);
}

Result:

200
_Map<String, dynamic>
sunt aut facere repellat provident occaecati excepturi optio reprehenderit
  • Dio() is the client that requests go through.
  • dio.get(address) is a GET request. The address is a plain string, unlike http , which needs a Uri .
  • response.data is the response already decoded from JSON : it is a Map , so response.data['title'] works right away, without jsonDecode() . dio decodes the response when the server sends it with the application/json type.

4. Errors: a 404 is already an exception

This is the first difference you will notice after Lesson 7. There, a response with code 404 arrived as an ordinary response, and you checked the code yourself. By default dio treats only codes 200 to 299 as success (the validateStatus parameter changes that) and throws a DioException for any other:

import 'package:dio/dio.dart';

Future<void> load(Dio dio, String url) async {
  try {
    final response = await dio.get(url);
    print('OK ${response.statusCode}');
  } on DioException catch (e) {
    print('${e.type}: ${e.response?.statusCode}');
  }
}

Future<void> main() async {
  final dio = Dio();
  await load(dio, 'https://jsonplaceholder.typicode.com/posts/0');
  await load(dio, 'https://no-such-host.invalid/posts/1');
}

Result:

DioExceptionType.badResponse: 404
DioExceptionType.connectionError: null
  • on DioException catch (e) catches any request error.
  • e.type is the kind of error. badResponse — the server replied with a code outside 200–299; connectionError — the server could not be reached (here, there is no such server).
  • e.response?.statusCode is the response code. When there is no response at all, e.response is null .

Other kinds of errors: connectionTimeout , sendTimeout , receiveTimeout — time ran out; cancel — the request was cancelled; unknown — some other error, with the original exception in e.error . A certificate error arrives this way, for example: for an expired or self-signed certificate e.type is unknown and e.error is a HandshakeException . The badCertificate kind only appears if you set up your own certificate check. The full list is in the DioExceptionType reference.

5. Shared options: baseUrl and timeouts

The task: stop repeating the server address and time limits in every request. They are set once in BaseOptions :

import 'package:dio/dio.dart';

final dio = Dio(
  BaseOptions(
    baseUrl: 'https://jsonplaceholder.typicode.com',
    connectTimeout: const Duration(seconds: 5),
    receiveTimeout: const Duration(seconds: 10),
  ),
);

Future<void> main() async {
  final post = await dio.get('/posts/1');
  final user = await dio.get('/users/1');
  print(post.data['title']);
  print(user.data['name']);
}

Result:

sunt aut facere repellat provident occaecati excepturi optio reprehenderit
Leanne Graham
  • baseUrl is the start of the address. The request dio.get('/posts/1') goes to https://jsonplaceholder.typicode.com/posts/1 .
  • connectTimeout is how long to wait for a connection to the server.
  • receiveTimeout is how long to wait for data: the first bytes of the response and every next chunk. It is not a limit on the whole response: a large file can take longer to download if the data keeps coming without pauses.
  • By default dio doesn’t limit the waiting time — a request can hang for a very long time. So it is worth setting timeouts.
  • Headers for all requests are set here too — the headers parameter.

When the time runs out, dio throws a DioException of the matching kind. Let’s check with a deliberately tiny receiveTimeout :

import 'package:dio/dio.dart';

Future<void> main() async {
  final dio = Dio(
    BaseOptions(receiveTimeout: const Duration(milliseconds: 1)),
  );
  try {
    await dio.get('https://jsonplaceholder.typicode.com/posts');
  } on DioException catch (e) {
    print(e.type);
  }
}

Result: DioExceptionType.receiveTimeout .

The dio authors recommend keeping a single `Dio` instance in an app: then the options are the same for all requests. In the example above it is declared once at the top level of the file.

6. POST: sending JSON

The task: send a new post to the server.

import 'package:dio/dio.dart';

Future<void> main() async {
  final dio = Dio(BaseOptions(baseUrl: 'https://jsonplaceholder.typicode.com'));
  final response = await dio.post(
    '/posts',
    data: {'title': 'Hello', 'body': 'My first post', 'userId': 1},
  );
  print(response.statusCode);
  print(response.requestOptions.contentType);
  print(response.data);
}

Result:

201
application/json
{title: Hello, body: My first post, userId: 1, id: 101}
  • data: is the request body. dio turns a Map into JSON by itself — no jsonEncode() needed.
  • dio also sets the Content-Type: application/json header itself — you can see it in the second line of the result.
  • The reply is code 201 “created” and a post with number 101 (JSONPlaceholder saves nothing; it only replies as if it had).

7. Interceptors: code before every request

The task: see every request and response in the console, and add an authorization token to every request — for example, one received after signing in to the app. A constant header is easier to set in BaseOptions ; here we need code that runs on every request. That is an interceptor: code that dio calls before every request and after every response.

import 'package:dio/dio.dart';

Future<void> main() async {
  final dio = Dio(BaseOptions(baseUrl: 'https://jsonplaceholder.typicode.com'));
  dio.interceptors.add(
    InterceptorsWrapper(
      onRequest: (options, handler) {
        options.headers['Authorization'] = 'Bearer my-token';
        print('→ ${options.method} ${options.uri}');
        handler.next(options);
      },
      onResponse: (response, handler) {
        print('← ${response.statusCode}');
        handler.next(response);
      },
    ),
  );
  await dio.get('/posts/1');
  await dio.get('/users/1');
}

Result:

→ GET https://jsonplaceholder.typicode.com/posts/1
← 200
→ GET https://jsonplaceholder.typicode.com/users/1
← 200
  • onRequest is called before every request: here we add the Authorization header and print the address.
  • onResponse — after every response; onError — on every error.
  • handler.next(...) passes the request or response on — to the next interceptor. Call it at the end of every handler, as in the example.

For a request log, dio has a ready-made `LogInterceptor` . By default it doesn’t print request and response bodies, and in Flutter the dio authors recommend printing its log with debugPrint : LogInterceptor(logPrint: (o) => debugPrint(o.toString())) .

8. Cancelling a request

The task: the user has left the screen, but the request is still running — its result is no longer needed. A request can be cancelled with a CancelToken :

import 'package:dio/dio.dart';

Future<void> main() async {
  final dio = Dio();
  final cancelToken = CancelToken();
  final request = dio.get(
    'https://jsonplaceholder.typicode.com/posts',
    cancelToken: cancelToken,
  );
  cancelToken.cancel('The user left the screen');
  try {
    await request;
  } on DioException catch (e) {
    print('${e.type}: ${e.error}');
  }
}

Result: DioExceptionType.cancel: The user left the screen .

  • cancelToken: is passed into the request.
  • cancelToken.cancel(reason) cancels it: the request ends with a DioException of kind cancel , and e.error holds the reason.
  • One CancelToken can cancel several requests at once.

9. Downloading a file with progress

The task: download a large file and show how much has been downloaded. That is what dio.download is for — it saves the response to a file and reports progress along the way:

import 'package:dio/dio.dart';

Future<void> main() async {
  final dio = Dio();
  await dio.download(
    'https://jsonplaceholder.typicode.com/photos',
    'photos.json',
    onReceiveProgress: (received, total) {
      if (total > 0) {
        print('${(received / total * 100).toStringAsFixed(0)}%');
      } else {
        print('$received bytes');
      }
    },
  );
  print('Done');
}

Result (beginning and end):

670 bytes
9342 bytes
…
1039898 bytes
Done
  • The second argument is the path of the file to save to. In the console it is just a file name in the current folder; in an app the path is taken from the app’s folder — for example, with getTemporaryDirectory() from the path_provider package, as in the `download` reference .
  • onReceiveProgress is called as the download goes: received is how many bytes have arrived, total is the full size.
  • If the server didn’t report the size (the content-length header) or sent the data compressed (the content-encoding: gzip header, as here), total is −1. Then you can’t show percent, only the amount downloaded.
  • On the web the browser doesn’t let you write files anywhere: there download downloads the file into memory and offers to save it, and the path becomes the suggested file name .

A large file and memory. On a phone or a computer, download doesn’t keep the whole file in memory: it writes each chunk that arrives to disk and only then accepts the next one. In our test the program’s memory didn’t grow with the file size: downloading a 3 GB file, it was the same as when downloading with Dart’s own tools ( dart:io ). So download large files — videos, archives, updates — with download .

10. Uploading files to a server

The task: send a photo to a server — for example, to a profile or an album. To send a file together with form fields, the multipart/form-data format is used; in dio that is FormData .

This example needs two things:

  • a server that accepts files. The code has the placeholder https://example.com/upload instead of its address — put in your own server’s address. Which address and which field names it expects is in its documentation;
  • a photo.jpg picture in the dio_demo project folder, next to pubspec.yaml : the file path is counted from the folder where you run dart run . If the file is missing, the program stops with a PathNotFoundException .
import 'package:dio/dio.dart';

const uploadUrl = 'https://example.com/upload';

Future<void> main() async {
  final dio = Dio();
  final formData = FormData.fromMap({
    'title': 'Vacation',
    'photo': await MultipartFile.fromFile('photo.jpg'),
  });
  final response = await dio.post(uploadUrl, data: formData);
  print(response.statusCode);
  print(response.data);
}

What comes back is up to the server. Our test server replied with code 200 and a list of what it received:

200
{fields: {title: Vacation}, files: [{field: photo, filename: photo.jpg, size: 150000}]}
  • FormData.fromMap builds the form: ordinary fields ( 'title' ) and files ( 'photo' ) together.
  • MultipartFile.fromFile(path) is a file from disk. It is read in parts , not as a whole: in our test the program’s memory barely grew when uploading a 1 GB file.
  • data: formData — the form goes as the body of the POST request; dio sets the Content-Type header itself.
  • On the web there are no file paths, and fromFile throws an UnsupportedError there. The file is passed as bytes — with MultipartFile.fromBytes .

Several files go as a list in one field, and onSendProgress shows the upload progress:

import 'package:dio/dio.dart';

const uploadUrl = 'https://example.com/upload';

Future<void> main() async {
  final dio = Dio();
  final formData = FormData.fromMap({
    'photos': [
      await MultipartFile.fromFile('photo1.jpg'),
      await MultipartFile.fromFile('photo2.jpg'),
    ],
  });
  final response = await dio.post(
    uploadUrl,
    data: formData,
    onSendProgress: (sent, total) {
      print('${(sent / total * 100).toStringAsFixed(0)}%');
    },
  );
  print(response.data);
}

Result (beginning and end, the reply of our test server):

0%
13%
…
100%
{fields: {}, files: [{field: photos, filename: photo1.jpg, size: 200000}, {field: photos, filename: photo2.jpg, size: 300000}]}
  • In a list field every file goes under the same name — photos , as the server’s reply shows. The dio README says the name becomes files[] , but dio 5.11.1 sends it without brackets.
  • If the server expects the name with brackets — photos[] , as is customary in PHP, for example — pass ListFormat.multiCompatible as the second argument: FormData.fromMap({...}, ListFormat.multiCompatible) .
  • onSendProgress works like onReceiveProgress from section 9: sent is how many bytes have been sent, total is how many in all. With FormData the size is known in advance, so there is no need to check for −1 here.
  • FormData and MultipartFile are good for one request. To send again, create them anew, as the dio README advises, or make a copy with formData.clone() .

11. dio in a Flutter app

Here is the fetchPost function from Lesson 7 rewritten with dio, together with a screen that loads the post when it opens. In the Lesson 7 project, run flutter pub add dio and replace lib/main.dart entirely. The network permissions from Lesson 7 (section 2) are needed here too.

import 'package:dio/dio.dart';
import 'package:material_ui/material_ui.dart';

final dio = Dio(
  BaseOptions(
    baseUrl: 'https://jsonplaceholder.typicode.com',
    connectTimeout: const Duration(seconds: 5),
    receiveTimeout: const Duration(seconds: 10),
  ),
);

class Post {
  const Post({required this.id, required this.title, required this.body});

  final int id;
  final String title;
  final String body;

  factory Post.fromJson(Map<String, dynamic> json) {
    return switch (json) {
      {'id': int id, 'title': String title, 'body': String body} => Post(
        id: id,
        title: title,
        body: body,
      ),
      _ => throw const FormatException('Could not read the post'),
    };
  }
}

Future<Post> fetchPost(int id) async {
  final response = await dio.get('/posts/$id');
  return Post.fromJson(response.data as Map<String, dynamic>);
}

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(title: const Text('dio')),
        body: const PostView(),
      ),
    );
  }
}

class PostView extends StatefulWidget {
  const PostView({super.key});

  @override
  State<PostView> createState() => _PostViewState();
}

class _PostViewState extends State<PostView> {
  late Future<Post> _post;

  @override
  void initState() {
    super.initState();
    _post = fetchPost(1);
  }

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.all(16),
      child: FutureBuilder<Post>(
        future: _post,
        builder: (context, snapshot) {
          if (snapshot.hasError) {
            return Text('Error: ${snapshot.error}');
          }
          if (!snapshot.hasData) {
            return const Center(child: CircularProgressIndicator());
          }
          final post = snapshot.data!;
          return Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              Text(post.title, style: Theme.of(context).textTheme.titleLarge),
              const SizedBox(height: 8),
              Text(post.body),
            ],
          );
        },
      ),
    );
  }
}

What changed compared with Lesson 7:

  • the server address and timeouts are set once in BaseOptions ;
  • there is no jsonDecode() — response.data is already a Map ;
  • there is no statusCode check: for a code outside 200–299 dio throws a DioException itself, and FutureBuilder shows “Error: …”.

12. http vs dio, point by point

  • A response with code 404 or 500. http — an ordinary response, you check the code yourself. dio — a DioException of kind badResponse .
  • JSON in the response. http — jsonDecode(response.body) . dio — response.data is already decoded.
  • JSON in the request. http — jsonEncode() and the Content-Type header by hand. dio — data: {...} , the rest it does itself.
  • Timeout. http — .timeout() on the Future . dio — connectTimeout , receiveTimeout , sendTimeout in the options.
  • Shared headers and logging. http — your own wrapper class around `Client` . dio — interceptors.
  • Cancellation. http — AbortableRequest (since version 1.5.0). dio — CancelToken .
  • Downloading with progress. Only dio has a ready-made method — download with onReceiveProgress .
  • Uploading files. http — MultipartRequest . dio — FormData in data: , progress with onSendProgress .
  • Who publishes it. http — the Dart team ( dart.dev ). dio — CFUG ( flutter.cn ).

13. Common mistakes

  • DioError is deprecated. Since dio 5.2.0 the error class is called DioException ; DioError is kept as deprecated. In old examples from the internet, replace DioError with DioException .
  • Expecting a 404 in the response and getting an exception. If you are used to http , catch a response with an error code in on DioException catch (e) and look at e.response?.statusCode .
  • A new Dio() for every request. Then you have to set the options and interceptors again each time. Create one instance for the app.
  • A request hangs for a very long time. Timeouts are not set by default — set them in BaseOptions .

A certificate error (a HandshakeException inside a DioException ) and how to trust your own certificate — in a separate article about HTTPS and certificates (coming soon).

14. Summary

  • dio is an HTTP client with shared options ( BaseOptions ), interceptors, cancellation ( CancelToken ), downloading ( download ) and uploading files ( FormData ).
  • On a phone or a computer, download writes large files to disk in chunks and MultipartFile.fromFile reads them in parts — the whole file never sits in memory.
  • response.data is already decoded from JSON, and a Map in data: is sent as JSON.
  • By default a response with a code outside 200–299 is a DioException ; the kind of error is in e.type .
  • Timeouts are not set by default — set them yourself.
  • For a couple of simple requests the http package is enough — Lesson 7 .

Send questions and suggestions about the article to contact@flutter.su.