DEV Community

Alexis
Alexis

Posted on

Why Your .NET Tests Are 10x Slower on Windows (and How to Fix It)

TL;DR: On Windows, localhost resolves 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:

  1. Windows DNS returns both ::1 (IPv6) and 127.0.0.1 (IPv4)
  2. .NET prefers IPv6 and tries ::1 first
  3. Docker containers bind to 0.0.0.0 (IPv4 only)
  4. The IPv6 connection hangs for ~2 seconds
  5. .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;
            }
        }
    });
});
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

Then apply it per-library:

PostgreSQL (Npgsql)

var builder = new NpgsqlConnectionStringBuilder(connectionString);
builder.Host = LocalhostExtensions.NormalizeToIPv4(builder.Host);
return builder.ConnectionString;
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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());
Enter fullscreen mode Exit fullscreen mode

Raw HttpClient (Test Code)

var endpoint = app.GetEndpoint("my-api");
var baseAddress = LocalhostExtensions.NormalizeToIPv4(endpoint);
var client = new HttpClient { BaseAddress = new Uri(baseAddress) };
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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)