This is a personal note that I decided to share. It reflects my understanding of a subject and may contain errors and approximations. Feel free to contribute by contacting me here. Any help will be credited!
Recently, with my team, we ran into a network issue involving a dual-stack host (IPv4 and IPv6) in a Flutter project. We explored the Dart SDK and uncovered some interesting details about its networking. This note summarizes what I learned.
Thanks to Adrien Audouard for his contribution and his review.
The Dart SDK includes an HTTP Client in dart:io
package, providing a simple and lightweight client to communicate with servers. It leverages platform-specific code for low-level network operations. In most applications, the Dart HTTP client is a Singleton (cf. Singleton Pattern).
dio
is a library built on top of the Dart HTTP Client. It adds support for several common features such as:
- Interceptors.
- Automatic JSON Deserialization.
- Timeouts.
Internally, dio
uses a Dart HTTP Client.
dio
is widely used in the Dart community, especially with the framework Flutter.
Internal Structure of dart:io
HTTP Client: Connection Management & Pooling
The implementation of the Dart HTTP client is located in files http.dart
and http_impl.dart
.
The HTTP client stores a map of _ConnectionTarget
instances, each representing a remote server. Targets are keyed by host
, port
, and scheme
. For example, if your client communicates with https://alexis-segura.com
and https://google.com
, then it will have two connection targets.
Each target maintains two sets of _HttpClientConnection
objects:
_idle
connections: waiting to handle requests._active
connections: currently handling requests.
Those two sets form a connection pool. Each connection is a TCP socket with the server. When multiple requests are made, connections are reused, which improves performance.
When there is no available connection in the _idle
set, a new connection is created, according to the maxConnectionPerHost
property. When the pool reaches the maximum number of connections to a host, it does not open a new connection right away. Instead, it queues up a connection request using a Completer
. When one of the existing connections closes, Dart dequeues the next connection request.
It is possible to provide a connectionFactory
to override the default connection behavior.
DNS, sockets, and connection logic
In Dart HTTP Client, most of the code is written in Dart. However, Dart delegates specific network actions to platform code (native code from the underlying OS).
On a new connection, the client:
- Performs DNS lookup. It returns IPv4 and IPv6 (if supported). This lookup is performed by platform code.
- Establishes a socket connection using a
RawSocket
for HTTP, or aSecureSocket
with aRawSocket
for HTTPS. - Uses
_NativeSocket
insideRawSocket
to bridge Dart code with the platform’s socket APIs.

A diagram to understand how it works (probably inaccurate)
Connection Initialization: A Non-Standard Approach
As we saw, Dart leverages only specific networking actions from the underlying OS. It means that several parts of HTTP communications are managed by Dart code. This is the case to connect to remote hosts over TCP after DNS lookup.
Let’s say we want to perform a GET on an API. We initiate a call to https://google.com
, the client:
- Creates a
_ConnectionTarget
and a_HttpClientConnection
. - Instantiates a
RawSocket
and a_NativeSocket
. - A DNS lookup is performed, returning a set of IPv4 and IPv6 (in the case of dual-stack hosts).
- Then the method
tryConnectToResolvedAddresses
in_NativeSocket
is called, to connect over TCP to one of the returned IPs. - Once the connection is set up, the request is made.
The tryConnectToResolvedAddresses
method can be found in the socket_patch.dart
file. If we take a look at it, we can see that it attempts to connect to each resolved IP address in sequence. If a connection does not succeed within 250ms (default _retryDuration
), it moves on to the next address. This pattern is known as staggered parallelism, an approach where connection attempts are made in sequence with a short delay.
However, the way Dart is managing this connection initialization does not follow the standard defined by IETF: RFC 6555 and RFC 8305. Dart does not interleave IPv6 and IPv4 addresses. As a result, if the first addresses are from the same family (e.g., IPv6) and if it fails or is slow, the user may experience a delay before the fallback (e.g., IPv4) is attempted.
In order to manage dual-stack hosts, the IETF specified an algorithm in 2012: Happy Eyeball (RFC 6555). An updated version has been released in 2017: Happy Eyeball Version 2 (RFC 8305).
The RFC abstract states :
When a server’s IPv4 path and protocol are working, but the server’s IPv6 path and protocol are not working, and a dual-stack client application experiences significant connection delay compared to an IPv4-only client. This is undesirable because it causes dual– stack client to have a worse user experience. This document specifies requirements for algorithms that reduce this user-visible delay and provides an algorithm.
Happy Eyeball algorithm triggers several connection attempts in parallel, first with IPv6, then with IPv4 after a short delay. The first connection that succeeds will be used.
Alternatives: Platform’s HTTP Clients
Dart developers are not locked in with the Dart HTTP Client. It is possible to use other clients, especially the ones available on the underlying platform.
If you are using dio
, the Dart community developed a package called native_dio_adapter
. It provides Android (cronet_http
), iOS, and macOS (cupertino_http
) adapters for dio
.
While the Dart HTTP client delegates certain tasks, like DNS resolution, to the platform, it bypasses the complete platform networking stack.
By using cronet_http
or cupertino_http
with dio
, the application instantiates and uses the complete network layer of the platform.
OS like Android and iOS have more complete APIs, that follow standards much more closely. Notably, iOS has implemented the Happy Eyeball algorithm since version 5 (2011).