TL;DR: On Windows,
localhostresolves to IPv6 first. Docker containers only listen on IPv4. Each connection waits 2-4 seconds for the IPv6 timeout before falling back. Fix it by forcing IPv4 at the socket level.
Our .NET Aspire integration tests took 15 minutes. They should have taken 2 minutes. Every test waited 5+ seconds before doing anything useful.
The culprit: Windows trying IPv6 for localhost when Docker only speaks IPv4.
The Problem
When you connect to localhost on Windows:
- Windows DNS returns both
::1(IPv6) and127.0.0.1(IPv4) - .NET prefers IPv6 and tries
::1first - Docker containers bind to
0.0.0.0(IPv4 only) - The IPv6 connection hangs for ~2 seconds
- .NET falls back to IPv4 and succeeds
On Linux, an IPv6 connection to a non-listening port gets an immediate "connection refused." Windows just waits.
The Multiplication Effect
A single Aspire integration test might make:
| Component | Connections |
|---|---|
| Test → API endpoints | 2-3 HTTP calls |
| Service → Service | 2-4 HTTP calls |
| Services → PostgreSQL | 1+ connections |
| Services → Redis | 1+ connections |
| JWT validation → JWKS | 1 HTTP call |
Each localhost connection adds ~2 seconds of dead time. Ten connections per test means 20+ seconds of pure waiting.
The .NET Issue
This is documented in dotnet/runtime#65375: "HttpClient.GetAsync is really slow to localhost, really fast to 127.0.0.1." Closed as "not actionable" because it's OS behavior, not a .NET bug. Affects .NET 6+ and .NET Core 3.1.
The Fix: Force IPv4 at Socket Level
For HttpClient instances created via IHttpClientFactory, intercept the connection and force IPv4:
using System.Net;
using System.Net.Sockets;
builder.Services.ConfigureHttpClientDefaults(http =>
{
http.ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler
{
ConnectCallback = async (context, cancellationToken) =>
{
var host = context.DnsEndPoint.Host;
// Force IPv4 for localhost, let OS decide for everything else
var addressFamily = host.Equals("localhost", StringComparison.OrdinalIgnoreCase)
? AddressFamily.InterNetwork // IPv4 only
: AddressFamily.Unspecified;
var entry = await Dns.GetHostEntryAsync(host, addressFamily, cancellationToken);
var socket = new Socket(SocketType.Stream, ProtocolType.Tcp) { NoDelay = true };
try
{
await socket.ConnectAsync(entry.AddressList, context.DnsEndPoint.Port, cancellationToken);
return new NetworkStream(socket, ownsSocket: true);
}
catch
{
socket.Dispose();
throw;
}
}
});
});
This covers all HttpClient instances registered through IHttpClientFactory.
Non-HTTP Connections
Different libraries need different fixes. Create a shared utility:
public static class LocalhostExtensions
{
public static string NormalizeToIPv4(string value)
{
if (string.IsNullOrEmpty(value))
return value;
return value.Replace("localhost", "127.0.0.1", StringComparison.OrdinalIgnoreCase);
}
public static Uri NormalizeToIPv4(Uri uri)
{
if (!uri.Host.Equals("localhost", StringComparison.OrdinalIgnoreCase))
return uri;
return new UriBuilder(uri) { Host = "127.0.0.1" }.Uri;
}
}
Then apply it per-library:
PostgreSQL (Npgsql)
var builder = new NpgsqlConnectionStringBuilder(connectionString);
builder.Host = LocalhostExtensions.NormalizeToIPv4(builder.Host);
return builder.ConnectionString;
Redis (StackExchange.Redis)
var connectionString = LocalhostExtensions.NormalizeToIPv4(originalConnectionString);
var options = ConfigurationOptions.Parse(connectionString);
// TLS cert is issued for 'localhost', not '127.0.0.1'
// WARNING: Only bypass validation in test environments
options.CertificateValidation += (sender, cert, chain, errors) => true;
return ConnectionMultiplexer.Connect(options);
JWT JWKS Discovery
var jwksEndpoint = $"{vaultBaseUrl.TrimEnd('/')}/.well-known/jwks.json";
jwksEndpoint = LocalhostExtensions.NormalizeToIPv4(jwksEndpoint);
var configManager = new ConfigurationManager<OpenIdConnectConfiguration>(
jwksEndpoint,
new JwksConfigurationRetriever(),
new HttpDocumentRetriever());
Raw HttpClient (Test Code)
var endpoint = app.GetEndpoint("my-api");
var baseAddress = LocalhostExtensions.NormalizeToIPv4(endpoint);
var client = new HttpClient { BaseAddress = new Uri(baseAddress) };
Summary of Fixes by Component
| Component | Fix Location |
|---|---|
| IHttpClientFactory clients | Socket-level ConnectCallback
|
| Npgsql | Connection string Host property |
| StackExchange.Redis | Connection string + TLS bypass |
| ConfigurationManager | URL before construction |
| Raw HttpClient | BaseAddress before construction |
Conditional Application
These fixes should only run in test mode:
var isTestingEnabled = configuration.GetValue<bool>("Testing:Enabled");
if (isTestingEnabled)
{
// Apply IPv4 normalization
}
Production deployments use real hostnames and aren't affected.
Results
| Metric | Before | After |
|---|---|---|
| Per-test time | ~5-6 seconds | ~0.6-0.8 seconds |
| Full suite (100 tests) | ~10 minutes | ~2 minutes |
5-8x faster by eliminating IPv6 timeout waits.
Top comments (0)