Everything You Always Wanted to Know About HttpClients

Thomas BurkhartThomas Burkhart
Jul 04, 2024·
16 min read

In case you are wondering about the cover of this post, I had to share with you the incredible location I was writing this post

When starting out writing apps with Flutter or Dart, you probably, like me, just followed the examples on how to make HTTP requests and didn't think much about it. You might have noticed in the API docs of the global functions like get, post, etc., of the http package that it mentions that if you call the same server multiple times, it would be more efficient to reuse the same HttpClient. Unfortunately, this typically leads to more questions, which started me on an excursion into the wonders of HTTP implementations in Dart and Flutter.

This post will try to answer the following questions:

  1. How many HttpClients should you use and why?

  2. Should you close your Clients?

  3. Why should you use the new native HttpClient implementations?

  4. How to ensure Flutter uses the correct Clients?

  5. Pitfalls to avoid

How many HttpClients should an App use?

If you spend some time analyzing the network traffic of your app, which I highly recommend, with either the Network Tool of the Dart Development Tools or a professional proxy like Fiddler or Charles you will see that a request consists of two phases:

The Request and the Response phase. When you look at the timings, you'll notice that the request phase takes quite a long time. During this period, no data is transmitted to your app. In fact, most of this time is spent with

  • DNS resolution (35ms in this example)

  • TCP connect time (31ms)

  • HTTPS handshake (42ms)

If we look at some of the requests at the startup of our current app you will see:

You see a lot of blue bars, which means that for every request with a new HttpClient, a new connection needs to be established, taking up a lot of time.

But what about HTTP keep-alive headers?

Indeed, since HTTP 1.1, clients can tell the server to keep a connection alive, which avoids the time needed to establish a new connection. However:

  • This only works if you use the same HttpClient in Dart for all connections.

  • Even with keep-alive, one HTTP 1.1 connection can only be used once at a time. So, when you send many async requests, like at startup, don't expect them all to use the same connection. You would have to send them sequentially, which is likely slower than establishing additional connections in parallel.

Reusing the same Client everywhere in your app

The most straightforward approach is to register one client instance in a place in your app where you can easily access it, such as using get_it (disclaimer: I'm the author of get_it) or any other service locator. Then, use that client instance throughout your app (we will later see that this isn't as straightforward in Flutter as you might think). This also means no longer using the global HTTP functions of the http package but instead using the methods of the client instance.

After we manage to do this for all requests in our app the image already starts to change:

We still see many blue bars indicating new connections being created, but we also see some orange bars with little or no blue following them. This means they are using already established connections. Comparing the total time for all requests, we reduced the time by more than 10 seconds, which is remarkable since we only changed the reuse of HttpClients. One reason we didn't gain more is that we send many image requests to our imgix server in parallel.

HTTP/2 to the rescue

So far we always used the standard Dart HttpClient implementation that is part of the SDK. Let's have a closer look at the requests:

You can see that all the requests are made with the HTTP 1.1 protocol. Most of today's servers support HTTP 2, which allows multiple requests to be transmitted over one established connection. Fortunately, we now have new native HTTP clients for Flutter, at least for Android and iOS. The best part is that we don't need to change our app's logic at all, except to use the new client implementations. If we switch our app to use the new native clients, our requests will look like this:

At the very top, we see two HTTP 1.1 requests needed to negotiate with the server to use HTTP 2. (Two requests are made because we connect to two different servers.) After that, everything is done using HTTP 2.

It seems that some additional connections are created due to the many image requests, but we can see several orange bars in sequence without any blue. Comparing the total time of all 247 requests to the previous charts, we reduced the loading time by another 20 seconds. This highlights the importance of using the new native clients. All this was recorded on a very fast Wi-Fi connection. Over a cellular connection with higher latencies, this effect will be even more significant.

SetupTime for 247 Requests
DartIo Client, no Client reuse77s
DartIo Client, one Client for everything65,5s
cronet Client43,7

Surely, the differences will vary depending on the type of requests your app sends, but the gains will be significant.

So clearly, you should only use one Client per app unless you need different client settings. You might want a separate client to download large assets with package:cupertino_http so that allowsConstrainedNetworkAccess can be enabled. I also tested using two clients, one for imgix and one for our own server, but it didn't improve the timing; it actually got worse than using just one.

How to setup the Client for a Flutter App correctly

The httpClientFactory

Let's start with using the new native Http2 clients. For this, we have to add two new packages:

Then you have to create a platform-specific instance of a Client which is best done by using a factory method:

const _maxCacheSize = 2 * 1024 * 1024;

Client httpClientFactory() {
  try {
    if (Platform.isAndroid) {
      final engine = CronetEngine.build(
        cacheMode: CacheMode.memory,
        cacheMaxSize: _maxCacheSize,
        enableHttp2: true,
      );
      return CronetClient.fromCronetEngine(engine);
    }

    /// 'You must provide the Content-Length' HTTP header issue
    if (Platform.isIOS || Platform.isMacOS) {
      final config = URLSessionConfiguration.ephemeralSessionConfiguration()
        ..cache = URLCache.withCapacity(memoryCapacity: _maxCacheSize);
      return CupertinoClient.fromSessionConfiguration(config);
    }
  } catch (_) {
    /// in case no Cronet is available which can happen on Android without Google Play Services
    /// not sure if there is a similar case for Cupertino but better safe than sorry
    return IOClient(HttpClient());
  }
  final httpClient = HttpClient();
  // To use with Fiddler uncomment the following lines and set the
  // ip address of the machine where Fiddler is running
  // httpClient.findProxy = (uri) => 'PROXY 192.168.1.61:8866';
  // httpClient.badCertificateCallback =
  //     (X509Certificate cert, String host, int port) => true;
  return IOClient(httpClient);
}

A similar function can be found in the documentation of the http package. What isn't mentioned there is that cronet is only available on Android devices with Google Play Services installed. Otherwise, you will encounter this exception:

java.lang.RuntimeException: All available Cronet providers are disabled. A provider should be enabled before it can be used.

That's why the above code falls back to normal Dart IO Clients if Cronet is not available.

in case you want to be sure to have cronet available see this discussion on Github

Client type confusion

One thing to note is that CronetClient and CupertinoClient only implement the http.Client interface, not dart:io.HttpClient. This means you might not be able to use these instances in code that expects an HttpClient, like many examples you find online. Usually, it's not a big problem to adapt the code to use http.Client but don't be surprised if the analyzer tells you that your client is not a compatible type.

The reason for this is likely that with the introduction of Flutter-Web, web apps could not access dart:io, where HttpClient is defined. To write code that works across different platforms, they needed to introduce a new common parent interface for HTTP clients, which is http.Client.

So we have the following Client types:

TypePackagecompatible with http.ClientplatformHTTP
Clienthttpabstract interface
CronetClientcronet_httpAndroid with Play Services or statically linked2
CupertinoClientcupertino_httpiOS/macOS2
IoClienthttp✔ (uses HttpClient internally)all platforms but web1.1
BrowserClienthttponly web??
FetchClientfetch_clientonly web2/3
HttpClientdart:io(can be wrapped in IoClient)all but web

Your app should always use the http.Client interface to support all these client types. When creating a new client, use the Client() factory method to ensure the correct implementation is used. If you publish a package, it's best always to give users the option to pass a Client instance as an optional parameter like:

void myFuncThatNeedsAClient({Client? client}){
    final _client = client ?? Client();

Check the docs of BrowserClient and FetchClient for their specific limitations

What about the http2 package you might wonder?

There is an http2 package maintained by the Dart team, which is used as a base for the Dart gRPC package. Unfortunately, it does not implement the http.Client interface, and the documentation is almost non-existent. This StackOverflow question provides some insight on how to use it. It shouldn't be too difficult to implement http.Client based on this package, so let's hope the Dart team will do that soon. Also if you are using Dio you can use it with a special adapter.

The problem with runWithClient

According to the Dart docs, runWithClient should be the ideal solution to ensure that the app uses the new native clients everywhere. After implementing it, I became suspicious while analyzing the HTTP requests. Despite using runWithClient at the root level of our app, I noticed a large number of HTTP/1.1 requests. Upon closer inspection, all network requests for images from Image.Network were still using HTTP/1.1. After investigating further, it turns out that the underlying NetworkImage class uses some Flutter internal _HttpClient class and does not call the http.Client() factory constructor. The only alternative is to pass the client instance from httpClientFactory() directly to the parts of our code that need to make a network call.

Another pitfall with runWithClient I lately found out that if you need to create a client inside an isolate and you call Client() it won't call your provided httpClientFactory but use the default dart:io.HttpClient.

Registering the Client

Using the service locator or dependency injection of your choice, register one instance at the start of your app. Here, I demonstrate it with get_it:

  final di = GetIt.instance();
  /// note that we use additionally `runWithClient` on the project root, 
  // otherwise we couldn't just call `Client()`
  di.registerSingleton(Client());

  /// without `runWithClient`
  di.registerSingleton<Client>(httpClientFactory());

We currently use runWithClient even though we inject the registered Client everywhere with get_it. The main reason is to prevent future packages, which might use the Client() factory method internally, from using the wrong type of Client. If you already inject your Client everywhere, you can probably go without it.

Using one Client throughout the App

After registration, we can now access our single Client instance via GetIt.I<Client>().

If you are using watch_it, you can use di<Client>().

Unfortunately, there is no way to set Flutter's internal _sharedClient (I tried debugNetworkImageHttpClientProvider, which is used by NetworkImage, but it expects an http.HttpClient and not an http.Client).

Luckily, there isn't much complexity behind Flutter's Image.network(), which creates an Image widget using an ImageProvider of type NetworkImage.

The naming here is unfortunate because NetworkImage sounds like a widget but is actually an ImageProvider.

The easiest solution is to add your own implementation of a NetworkImageProvider to your project by using the original NetworkImage as a blueprint.

Flutter web uses its own version of NetworkImage. The following version might not be usable with Flutter web or might be less performant.

class HttpNetworkImage extends ImageProvider<HttpNetworkImage> {
  /// Creates an object that fetches the image at the given URL.
  const HttpNetworkImage(this.url, {this.scale = 1.0, this.headers});

  final String url;

  final double scale;

  final Map<String, String>? headers;

  @override
  Future<HttpNetworkImage> obtainKey(ImageConfiguration configuration) {
    return SynchronousFuture<HttpNetworkImage>(this);
  }

  @override
  ImageStreamCompleter loadImage(
      HttpNetworkImage key, ImageDecoderCallback decode) {
    // Ownership of this controller is handed off to [_loadAsync]; it is that
    // method's responsibility to close the controller's stream when the image
    // has been loaded or an error is thrown.

    return MultiFrameImageStreamCompleter(
      codec: _loadAsync(key, decode: decode),
      scale: key.scale,
      debugLabel: key.url,
      informationCollector: () => <DiagnosticsNode>[
        DiagnosticsProperty<ImageProvider>('Image provider', this),
        DiagnosticsProperty<HttpNetworkImage>('Image key', key),
      ],
    );
  }

  Future<ui.Codec> _loadAsync(
    HttpNetworkImage key, {
    required ImageDecoderCallback decode,
  }) async {
    try {
      assert(key == this);

      final Uri resolved = Uri.base.resolve(key.url);

      final response = await  GetIt.I<Client>().get(resolved, headers: headers);

      if (response.statusCode != HttpStatus.ok) {
        // The network may be only temporarily unavailable, or the file will be
        // added on the server later. Avoid having future calls to resolve
        // fail to check the network again.
        // await response.drain<List<int>>(<int>[]);
        throw NetworkImageLoadException(
            statusCode: response.statusCode, uri: resolved);
      }

      if (response.bodyBytes.isEmpty) {
        throw Exception('NetworkImage is an empty file: $resolved');
      }

      return decode(await ui.ImmutableBuffer.fromUint8List(response.bodyBytes));
    } catch (e) {
      // Depending on where the exception was thrown, the image cache may not
      // have had a chance to track the key in the cache at all.
      // Schedule a microtask to give the cache a chance to add the key.
      scheduleMicrotask(() {
        PaintingBinding.instance.imageCache.evict(key);
      });
      rethrow;
    }
  }

  @override
  bool operator ==(Object other) {
    if (other.runtimeType != runtimeType) {
      return false;
    }
    return other is HttpNetworkImage && other.url == url && other.scale == scale;
  }

  @override
  int get hashCode => Object.hash(url, scale);

  @override
  String toString() =>
      '${objectRuntimeType(this, 'HttpNetworkImage')}("$url", scale: ${scale.toStringAsFixed(1)})';
}

If you use provider instead of get_it as your locator you will have to pass the client to this image provider through a parameter.

Then you can create a network image like:

Image(image:HttpImage({my_image_url})),

If you don't want to have this as part of your code, you can use the package http_image_provider. As we are using get_it, the above solution is more elegant because we don't have to pass the Client to every image, and IMHO it's interesting to show how an ImageProvider works.

SVG Images

If you are using flutter_svg, you're in luck because SvgPicture.network offers a client parameter where you can inject your client instance. It's probably best to create your own widget so that you don't have to pass the client everywhere:

class NetworkSvgPicture extends StatelessWidget {
  const NetworkSvgPicture(this.url,{super.key});

  final String url;

  @override
  Widget build(BuildContext context) {
    return SvgPicture.network(url,httpClient: GetIt.I<Client>());
  }
}

Using cached_network_image

Many of us use the popular and battle-tested cached_network_image to reduce the number of images our apps need to download on repeated starts. Its API unfortunately doesn't allow passing an HTTP client as a parameter, but you can pass a custom CacheManager instance, which means it's just one level of indirection more. We have to create and register our own CacheManager where we can provide the HTTP client to be used:

  const cacheKey = 'my_cache';
  final di = GetIt.instance();
  di.registerSingleton<Client>(httpClientFactory());
  di.registerSingleton<CacheManager>(
    DefaultCacheManager(
      Config(
        cacheKey,
        fileService: HttpFileService(
          httpClient: di<Client>(),
        ),
    stalePeriod: const Duration(days: 7),
    maxNrOfCacheObjects: 200,
      ),
    ),
  );

Again we create our own image widget

class CachedHtttpNetworkImage extends StatelessWidget {
  const CachedHtttpNetworkImage(this.url,{super.key});

  final String url;

  @override
  Widget build(BuildContext context) {
    return CachedNetworkImage(
        imageUrl: url,
    cacheManager:  GetIt.I<CacheManager>());
  }
}

Bonus cached network SVGs

At first glance, it seems impossible to use cached_network_image with SVG images. Luckily, Dan Field designed flutter_svg to be very flexible and elegant. Instead of an image provider, it uses a ByteLoader, which we can implement to use flutter_cache_manager, the underlying caching engine of cached_network_image.

class SvgCachedNetworkLoader extends SvgLoader<File> {
  const SvgCachedNetworkLoader(
    this.url, {
    this.headers,
    super.theme,
    super.colorMapper,
  });

  /// The [Uri] encoded resource address.
  final String url;

  /// Optional HTTP headers to send as part of the request.
  final Map<String, String>? headers;

  @override
  Future<File> prepareMessage(BuildContext? context) async {
    return await GetIt.I<CacheManager>.getSingleFile(url, headers: headers);
  }

  @override
  String provideSvg(File? message) {
    final Uint8List bytes = message!.readAsBytesSync();
    return utf8.decode(bytes, allowMalformed: true);
  }

  @override
  int get hashCode => Object.hash(url, headers, theme, colorMapper);

  @override
  bool operator ==(Object other) {
    return other is SvgCachedNetworkLoader &&
        other.url == url &&
        other.headers == headers &&
        other.theme == theme &&
        other.colorMapper == colorMapper;
  }

  @override
  String toString() => 'SvgCachedNetworkLoader($url)';
}
class CachedNetworkSvgPicture extends StatelessWidget {
  const CachedNetworkSvgPicture(this.url, {super.key});

  final String url;

  @override
  Widget build(BuildContext context) {
    return SvgPicture(SvgCachedNetworkLoader(url));
  }
}

Using dio

Many of you use the popular dio package for your HTTP requests. In that case, it is quite easy to use the new native clients because there is a dedicated package called native_dio_adapter. Here's how you can do it:

final dioClient = Dio();
dioClient.httpClientAdapter = NativeAdapter();

Unfortunately, dio doesn't use the http.Client interface internally, likely to avoid a dependency on the http package. However, they do have an adapter concept to connect to any HTTP client you can imagine.

After the publishing of this article I was told that there is a a special adapter to use the http2 package with Dio which would enable you to get all the benefits of HTTP2 on Windows and Linux. The package is called dio_http2_adapter

Avoiding Pitfalls

Att: MultipartRequests with cupertino_http

The current version 1.5.0 of cupertino_http on pub.dev has a bug when sending a MultipartRequest. It doesn't include the expected "Content-Length" header, which causes an error when uploading an image to AWS S3. This issue has been fixed, but you need to use a git reference to the package's GitHub repository until a new version is released on pub.

Should I close my Client at any time?

Despite the alarming warning in the API docs of the Client.close():

You should never close the shared instance of your Client. The warning only applies to Dart console apps. When mobile apps terminate, all resources are released automatically.

Why You Should Analyze Your Network Requests

If you don't check your network requests regularly, you might not realize that your app isn't using HTTP2, is making multiple requests when one would suffice, or is downloading more data than necessary because images are in the wrong format. Therefore, I recommend using the Dart Network tool or a debug proxy to see what your app is actually doing.

What are these weird WebSocket requests in the Dart Network tool?

When you use the Network Page of the Dart DevTools, you might have wondered about these unexpected requests:

It seems that this is a bug in the Network tool because these are not WebSocket requests but actually the connection part of your requests. See this issue on GitHub for more details on why the tool lists them separately. This should definitely be fixed.

Needed preparation to use a debug proxy

If you are using one of the new native HTTP clients, using an external proxy like Fiddler or Charles is now much easier because you can set the proxy settings in the Android or iOS/MacOS network settings. However, if you are using the DartIo Client because you are building on Windows or Linux, or you don't want to use the new native clients, you will find that this Client ignores the OS proxy settings. (This is how I discovered that Flutter NetworkImages were not using the new clients. Only the image requests didn't show up in Fiddler, which made me suspicious).

To use an external debug proxy with DartIo, you have to define the proxy when creating the client:

  final httpClient = HttpClient();
  httpClient.findProxy = (uri) => 'PROXY 192.168.1.61:8866';
  httpClient.badCertificateCallback =
       (X509Certificate cert, String host, int port) => true;
  return IOClient(httpClient);

The badCertificateCallback is necessary to prevent DartIo from complaining about the certificate used by the proxy.

If you want to read the content of your requests and you are using SSL/TLS in your app(which you should), you need to install the proxy's certificates on your test device. Fiddler provides an excellent step-by-step guide on how to set up mobile devices. On Android, you need to add a network_security_config.

42
Subscribe to my newsletter

Read articles from Thomas Burkhart directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Thomas Burkhart
Thomas Burkhart