BrandGhost
HTTP/3 in .NET 10: Enabling QUIC with HttpClient and ASP.NET Core

HTTP/3 in .NET 10: Enabling QUIC with HttpClient and ASP.NET Core

HTTP/3 in .NET 10: Enabling QUIC with HttpClient and ASP.NET Core

If you have been building HTTP services in .NET for any length of time, you have probably noticed that things just keep getting faster. HTTP/3 in .NET is the next step in that evolution -- and in .NET 10, it is fully production-ready for both clients and servers. This guide walks through everything you need to know to get HTTP/3 running with HttpClient and ASP.NET Core Kestrel, from the fundamentals of QUIC all the way to a complete working example.

This is a deep topic. Let's break it down systematically.

For a broader look at how HttpClient works in .NET 10, check out HttpClient in C#: The Complete Guide first -- it covers the full lifecycle, IHttpClientFactory, and best practices that complement everything discussed here.


What Is HTTP/3 and Why Does QUIC Matter?

HTTP/3 is the third major version of the Hypertext Transfer Protocol. Unlike HTTP/1.1 and HTTP/2, which both run over TCP, HTTP/3 runs over QUIC -- a transport protocol built on top of UDP.

That single architectural shift solves several long-standing problems.

Head-of-Line Blocking

HTTP/2 introduced multiplexing, letting multiple requests share a single TCP connection. That's great. The problem is TCP itself: if one packet is lost, the entire connection stalls waiting for retransmission. Every stream -- even unrelated ones -- gets blocked. This is TCP head-of-line blocking, and it is a fundamental limitation of the protocol.

QUIC solves this at the transport layer. Each QUIC stream is independent. A lost packet on stream A doesn't block stream B. You get multiplexing without the head-of-line blocking penalty.

0-RTT Connection Establishment

A standard TLS 1.3 handshake over TCP takes at least one round-trip before any data can flow. QUIC combines the transport and cryptographic handshake, reducing connection setup to one round-trip (1-RTT) on first connection. If the client has previously connected to the same server, it can use 0-RTT resumption to send data with the very first packet.

For latency-sensitive applications -- mobile clients, geographically distributed services -- this is a meaningful gain.

Connection Migration

TCP connections are identified by a four-tuple: source IP, source port, destination IP, destination port. Change any one of those and the connection breaks. This is a real problem for mobile devices that switch between Wi-Fi and cellular.

QUIC uses connection IDs to identify connections instead of network addresses. If your IP changes mid-connection, QUIC can migrate seamlessly without dropping or restarting the connection. HTTP/3 inherits this capability directly.


HTTP/3 Support Timeline in .NET

HTTP/3 support in .NET has evolved progressively:

  • .NET 6 -- HTTP/3 support landed as a preview feature behind an AppContext switch. It worked but was explicitly not production-ready.
  • .NET 7 -- HTTP/3 became production-ready on the client side (HttpClient). Server-side Kestrel support was also production quality.
  • .NET 8 and .NET 9 -- Incremental improvements: better TLS integration, improved QUIC stream handling, performance tuning.
  • .NET 10 -- HTTP/3 is a first-class citizen. No feature flags required. HttpVersionPolicy behavior is well-defined and stable. QUIC performance has improved significantly.

In .NET 10, using HTTP/3 requires no special opt-in at the framework level. You configure it through standard APIs, and the runtime takes care of the rest.

Platform note: HTTP/3 relies on MsQuic under the hood. Starting with .NET 8, MsQuic is bundled directly in the .NET runtime on Windows x64 and Linux x64, so no OS-level dependency is required for most deployments. On older configurations or platforms without the bundled version, Windows 11 and Windows Server 2022 (and later) include MsQuic as an OS library. On Linux without the bundled runtime, the libmsquic package needs to be installed separately. The microsoft/dotnet container images used in Azure include it by default.


Enabling HTTP/3 in HttpClient with HttpVersionPolicy

The two properties you care about on HttpClient for http3 dotnet scenarios are DefaultRequestVersion and DefaultVersionPolicy.

using System.Net;
using System.Net.Http;

// Simple HTTP/3-preferred client
var client = new HttpClient
{
    DefaultRequestVersion = HttpVersion.Version30,
    DefaultVersionPolicy = HttpVersionPolicy.RequestVersionOrLower
};

var response = await client.GetAsync("https://example.com/api/data");
Console.WriteLine($"Response version: {response.Version}");

HttpVersion.Version30 maps to the HTTP/3 version constant. Setting DefaultRequestVersion alone is not enough -- you also need DefaultVersionPolicy to tell the runtime what to do when the requested version is not available.

If you are using IHttpClientFactory (which is the recommended approach for ASP.NET Core and hosted service applications), configure the handler through ConfigureHttpClient or by registering a typed client:

using Microsoft.Extensions.DependencyInjection;
using System.Net;
using System.Net.Http;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddHttpClient("http3-client", client =>
{
    client.DefaultRequestVersion = HttpVersion.Version30;
    client.DefaultVersionPolicy = HttpVersionPolicy.RequestVersionOrLower;
});

var app = builder.Build();
app.Run();

The named client "http3-client" will prefer HTTP/3 for all requests, falling back gracefully if the server does not support it.


HttpVersionPolicy: RequestVersionExact vs RequestVersionOrHigher vs RequestVersionOrLower

The HttpVersionPolicy enum controls what happens when the requested HTTP version is not negotiable. Understanding the difference between the three values is important for production correctness.

RequestVersionOrLower (the safe default)

This is the most forgiving option. The client asks for the specified version. If the server doesn't support it, the runtime falls back to the next lower version -- HTTP/2, then HTTP/1.1.

// Prefer HTTP/3, fall back to HTTP/2 or HTTP/1.1 automatically
var request = new HttpRequestMessage(HttpMethod.Get, "https://api.example.com/data")
{
    Version = HttpVersion.Version30,
    VersionPolicy = HttpVersionPolicy.RequestVersionOrLower
};

Use this when you want the performance benefits of HTTP/3 where available but cannot risk failures on servers that don't support it yet.

RequestVersionOrHigher

The client requests at least the specified version. If the server supports a higher version, the runtime may negotiate up.

// Request at least HTTP/2, upgrade to HTTP/3 if server supports it
var request = new HttpRequestMessage(HttpMethod.Get, "https://api.example.com/data")
{
    Version = HttpVersion.Version20,
    VersionPolicy = HttpVersionPolicy.RequestVersionOrHigher
};

This is useful when you have a minimum version requirement but want to benefit from improvements if available. Note that unlike RequestVersionOrLower, there is no graceful fallback here -- if the server cannot negotiate the minimum version or higher, the request throws HttpRequestException.

RequestVersionExact

The strictest option. The client requires exactly the specified version. If the server cannot fulfill that requirement, the request throws an HttpRequestException.

// Fail if HTTP/3 is not available -- useful for testing or strict environments
var request = new HttpRequestMessage(HttpMethod.Get, "https://api.example.com/data")
{
    Version = HttpVersion.Version30,
    VersionPolicy = HttpVersionPolicy.RequestVersionExact
};

try
{
    var response = await client.SendAsync(request);
}
catch (HttpRequestException ex)
{
    Console.WriteLine($"HTTP/3 not available: {ex.Message}");
}

Use RequestVersionExact in test scenarios where you want to verify that a specific endpoint is actually serving HTTP/3. In production, prefer RequestVersionOrLower unless you have a hard dependency on version-specific features.


TLS Requirements: HTTP/3 Requires HTTPS

This is non-negotiable. HTTP/3 requires HTTPS. More specifically, it requires TLS 1.3 or later. There is no plain-text HTTP/3.

This is by design -- QUIC was designed with security as a core requirement, not an afterthought. The TLS handshake is integrated into the QUIC connection establishment rather than layered on top, which is part of what makes the 0-RTT optimization possible.

In practice this means:

  1. Your server must have a valid TLS certificate (or a self-signed one accepted by the client, for local development).
  2. Your client must connect via https://. Attempting HTTP/3 over http:// will fail -- the runtime will either fall back to HTTP/1.1 or throw, depending on your VersionPolicy.
  3. Certificates must support TLS 1.3. Modern certificates from Let's Encrypt and most CAs do.

For local development, you can use the .NET development certificate (dotnet dev-certs https --trust) -- more on that in the testing section below.


Enabling HTTP/3 on the Server Side (ASP.NET Core Kestrel)

On the server side, Kestrel handles HTTP/3 through its Protocols configuration. You enable it per-endpoint.

using Microsoft.AspNetCore.Server.Kestrel.Core;

var builder = WebApplication.CreateBuilder(args);

builder.WebHost.ConfigureKestrel(options =>
{
    // HTTPS endpoint supporting all three versions
    options.ListenAnyIP(443, listenOptions =>
    {
        listenOptions.UseHttps();

        // Enable HTTP/1.1, HTTP/2, and HTTP/3 on this endpoint
        listenOptions.Protocols = HttpProtocols.Http1AndHttp2AndHttp3;
    });
});

var app = builder.Build();

app.MapGet("/", () => "Hello from HTTP/3!");

app.Run();

Kestrel will automatically add the Alt-Svc response header when HTTP/3 is enabled, advertising HTTP/3 availability to clients. Clients that support HTTP/3 will upgrade on subsequent requests.

You can also configure this through appsettings.json, which is often more practical in production deployments:

{
  "Kestrel": {
    "Endpoints": {
      "Https": {
        "Url": "https://*:443",
        "Protocols": "Http1AndHttp2AndHttp3"
      }
    }
  }
}

Both approaches are equivalent. The appsettings.json approach is easier to change per-environment without recompiling. For containerized deployments -- which are covered more fully in Deploying ASP.NET Core Web API to Azure and Docker -- environment variable overrides follow the same pattern using Kestrel__Endpoints__Https__Protocols.


Alt-Svc Header and Version Negotiation

HTTP/3 uses an upgrade discovery mechanism rather than direct negotiation. The first request to a server always uses HTTP/1.1 or HTTP/2 (via TCP). The server includes an Alt-Svc header in the response that says, in effect: "I also support HTTP/3 on this port."

Alt-Svc: h3=":443"; ma=86400

This tells the client: QUIC (HTTP/3) is available at port 443, and this advertisement is valid for 86,400 seconds (one day). The client caches this information and uses HTTP/3 for subsequent requests to the same origin.

This is why you will sometimes see the first request to a server use HTTP/2 and subsequent requests use HTTP/3 -- that is the protocol working as designed, not a bug.

Kestrel (since .NET 7) adds this header automatically when HttpProtocols.Http1AndHttp2AndHttp3 (or any combination including HTTP/3) is configured. You do not need to add it manually.

If you are using a reverse proxy in front of Kestrel -- nginx, Azure Application Gateway, or Cloudflare -- the proxy needs to forward or generate the Alt-Svc header itself. More on that in the containers and proxies section. If you want to understand how Kestrel's middleware pipeline processes request and response headers before they reach your application code, ASP.NET Core Middleware: Building and Using the Request Pipeline explains the full pipeline model including how headers flow through each middleware layer.


QUIC Connection Migration -- What It Means in Practice

Connection migration is one of the most compelling QUIC features for real-world applications. Here is what it actually means in day-to-day use.

When a mobile device running an HTTP/3 client switches from Wi-Fi to cellular, its IP address changes. In TCP, that breaks the connection entirely -- the OS has to establish a new TCP connection, do the TLS handshake again, and retransmit any in-flight requests. Users experience this as delays, dropped requests, or errors.

With QUIC connection migration, the client sends a PATH_CHALLENGE to the server on its new network path. The server validates the new path and updates the routing. The existing connection ID is preserved. In-flight streams continue without interruption.

In .NET 10, this is handled transparently by the MsQuic layer. There is nothing to configure in your application code. If the underlying network changes and the QUIC implementation can validate the new path, migration happens automatically.

The practical limitation is that many corporate firewalls and load balancers are session-affinity-aware at the IP level. If your load balancer pins connections by source IP, connection migration will break your routing. This is a deployment concern more than a .NET concern -- but worth understanding before assuming migration will work in all environments.


Testing HTTP/3 Locally with a Self-Signed Certificate

Testing http3 dotnet setups locally requires a working HTTPS endpoint. Here is a step-by-step approach.

First, trust the .NET development certificate:

dotnet dev-certs https --trust

Then configure your minimal API or test server with HTTP/3 enabled:

using Microsoft.AspNetCore.Server.Kestrel.Core;

var builder = WebApplication.CreateBuilder(args);

builder.WebHost.ConfigureKestrel(options =>
{
    options.ListenLocalhost(5001, listenOptions =>
    {
        listenOptions.UseHttps();
        listenOptions.Protocols = HttpProtocols.Http1AndHttp2AndHttp3;
    });
});

var app = builder.Build();

app.MapGet("/version-check", (HttpContext ctx) =>
    $"Protocol: {ctx.Request.Protocol}");

app.Run();

On the client side, you need to accept the development certificate. For testing, you can configure the handler to skip certificate validation (never do this in production):

using System.Net;
using System.Net.Http;

// Development/testing ONLY -- not for production
var handler = new SocketsHttpHandler
{
    SslOptions = new System.Net.Security.SslClientAuthenticationOptions
    {
        RemoteCertificateValidationCallback = (_, _, _, _) => true
    }
};

var client = new HttpClient(handler)
{
    DefaultRequestVersion = HttpVersion.Version30,
    DefaultVersionPolicy = HttpVersionPolicy.RequestVersionOrLower
};

var response = await client.GetStringAsync("https://localhost:5001/version-check");
Console.WriteLine(response); // Should print "Protocol: HTTP/3"

Run the server and client concurrently. The first request may negotiate HTTP/1.1 or HTTP/2 (via the Alt-Svc discovery cycle). Subsequent requests should use HTTP/3. Check ctx.Request.Protocol on the server or response.Version on the client to confirm.

Platform note for Linux: If you are developing on Linux, make sure libmsquic is installed. On Ubuntu/Debian: sudo apt-get install -y libmsquic. Without it, HTTP/3 will silently fall back to HTTP/2 and you will not see the expected protocol version.


HTTP/3 in Containers and Reverse Proxies

HTTP/3 adds a layer of complexity to typical deployment patterns.

The UDP Firewall Problem

QUIC runs on UDP, and many cloud environments and corporate networks block UDP by default -- or at least do not route it the same way as TCP. If your container or VM has UDP port 443 blocked at the network level, HTTP/3 will not work regardless of how you configure Kestrel.

In Azure, ensure your Network Security Group rules allow UDP on port 443 in addition to TCP. In Docker, map UDP and TCP:

# docker-compose.yml
services:
  api:
    image: myapp:latest
    ports:
      - "443:443/tcp"
      - "443:443/udp"   # Required for HTTP/3 / QUIC

nginx

As of nginx 1.25+, HTTP/3 support is available. It requires building nginx with the quiche or BoringSSL-based QUIC implementation. Standard package-manager nginx builds often do not include it. If you are using nginx as a reverse proxy, check your specific build's capabilities.

Azure Front Door and Cloudflare

Both Azure Front Door and Cloudflare terminate HTTP/3 at the edge and proxy requests to your origin over HTTP/1.1 or HTTP/2. This means:

  1. Clients connecting to the CDN edge get HTTP/3 benefits (faster handshakes, connection migration).
  2. Requests from the edge to your origin go over HTTP/2 or HTTP/1.1 -- which is fine for internal communication.

You do not need HTTP/3 enabled on your origin server when using these services. The edge handles it. This is a practical simplification for most production deployments.


When to Use HTTP/3 vs HTTP/2 vs HTTP/1.1

Understanding when http3 dotnet makes sense requires understanding what each version is optimized for.

Scenario Recommended Version
Modern browser clients, public APIs HTTP/3 preferred (RequestVersionOrLower)
gRPC services HTTP/2 (gRPC is built on HTTP/2 framing)
Internal service-to-service in a datacenter HTTP/2 (low latency, TCP is fine on stable networks)
Legacy server compatibility HTTP/1.1
Mobile-heavy workloads HTTP/3 (connection migration benefit)
High packet-loss environments HTTP/3 (QUIC handles loss per-stream)
Behind a proxy that doesn't support HTTP/3 HTTP/2 or HTTP/1.1

The practical guidance for most new .NET 10 APIs is: enable HTTP/1.1, HTTP/2, and HTTP/3 on Kestrel with HttpProtocols.Http1AndHttp2AndHttp3. Let the Alt-Svc negotiation do the work. Clients that can use HTTP/3 will. Clients that can't will fall back gracefully.

For HttpClient in service-to-service calls inside a datacenter, RequestVersionOrLower with Version30 is a safe default that gives you HTTP/3 benefits where possible without risking failures on HTTP/2-only endpoints.


Complete .NET 10 Example: Client and Server Both on HTTP/3

Here is a self-contained example that shows a Kestrel server and a HttpClient consumer both configured for HTTP/3.

Server (Program.cs):

using Microsoft.AspNetCore.Server.Kestrel.Core;

var builder = WebApplication.CreateBuilder(args);

builder.WebHost.ConfigureKestrel(options =>
{
    options.ListenLocalhost(7443, listenOptions =>
    {
        listenOptions.UseHttps();
        listenOptions.Protocols = HttpProtocols.Http1AndHttp2AndHttp3;
    });
});

var app = builder.Build();

// Report the actual protocol version used for each request
app.MapGet("/ping", (HttpContext ctx) => new
{
    Protocol = ctx.Request.Protocol,
    Timestamp = DateTimeOffset.UtcNow
});

app.Run();

Client (Client.cs):

using System.Net;
using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json.Serialization;

// In a real app, use IHttpClientFactory -- this is simplified for clarity
var handler = new SocketsHttpHandler
{
    // Accept dev cert for local testing only
    SslOptions = new System.Net.Security.SslClientAuthenticationOptions
    {
        RemoteCertificateValidationCallback = (_, _, _, _) => true
    }
};

var client = new HttpClient(handler)
{
    BaseAddress = new Uri("https://localhost:7443"),
    DefaultRequestVersion = HttpVersion.Version30,
    DefaultVersionPolicy = HttpVersionPolicy.RequestVersionOrLower
};

// First request: may use HTTP/1.1 or HTTP/2 while Alt-Svc is discovered
var first = await client.GetFromJsonAsync<PingResponse>("/ping");
Console.WriteLine($"First request -- Protocol: {first?.Protocol}");

// Brief pause to let Alt-Svc cache populate
await Task.Delay(100);

// Subsequent requests should negotiate HTTP/3
for (var i = 0; i < 3; i++)
{
    var result = await client.GetFromJsonAsync<PingResponse>("/ping");
    Console.WriteLine($"Request {i + 2} -- Protocol: {result?.Protocol}");
}

record PingResponse(
    [property: JsonPropertyName("protocol")] string Protocol,
    [property: JsonPropertyName("timestamp")] DateTimeOffset Timestamp
);

Run both together and you will see the protocol version transition from HTTP/1.1 or HTTP/2 on the first hit to HTTP/3 on subsequent requests -- exactly as the Alt-Svc negotiation is designed to work.

If you are building RESTful APIs on top of this foundation, ASP.NET Core Web API in .NET: The Complete Guide covers the full controller and minimal API patterns that compose naturally with the HTTP/3 setup shown here.

For routing concerns in ASP.NET Core applications -- attribute routing, route templates, and constraints that work alongside any HTTP version -- see ASP.NET Core Routing: Attribute Routing, Route Templates, and Constraints.


Frequently Asked Questions

Does HTTP/3 in .NET 10 require any special NuGet packages?

No. HTTP/3 support is built into the System.Net.Http and Microsoft.AspNetCore.Server.Kestrel packages that ship with .NET 10. No additional NuGet packages are required. On Linux, you may need to install the OS-level libmsquic package, but that is a system dependency -- not a .NET package.

Why does my HttpClient still show HTTP/2 even after setting Version30?

The most common reason is Alt-Svc discovery. The very first request to a server uses TCP-based HTTP because the client does not yet know that the server supports QUIC. After receiving the Alt-Svc header, subsequent requests to the same origin will use HTTP/3. Try making a second request and check response.Version -- it should be 3.0.

Can I use HTTP/3 with gRPC in .NET 10?

gRPC in .NET is built on HTTP/2 framing (specifically h2c or h2). gRPC-over-HTTP/3 is a work in progress in the broader ecosystem. As of .NET 10, the standard Grpc.AspNetCore and Grpc.Net.Client packages use HTTP/2. If you need gRPC, use HttpProtocols.Http1AndHttp2 on the Kestrel endpoint. You can have separate endpoints for HTTP/3 REST and HTTP/2 gRPC on different ports.

What happens if the QUIC handshake fails at the OS level?

HttpClient falls back gracefully, provided your VersionPolicy is RequestVersionOrLower. The runtime will log a warning internally but will not throw. If you are using RequestVersionExact and QUIC fails, you will receive an HttpRequestException. Always check your VersionPolicy before investigating why HTTP/3 is "not working" -- the answer is often that it is working exactly as configured but choosing to fall back.

Is HttpVersionPolicy.RequestVersionExact safe for production?

Use it with caution. It is valuable for testing -- it proves that a specific endpoint is actually serving HTTP/3. In production, it makes sense only when HTTP/3 availability is guaranteed by your infrastructure (e.g., an internal service you fully control) and the failure mode for version mismatch is acceptable. For most public-facing clients calling external APIs, RequestVersionOrLower is the safest default.

Does HTTP/3 improve performance for all request types?

Not uniformly. HTTP/3 delivers the biggest gains in high-latency, lossy network conditions (mobile, intercontinental calls). For low-latency datacenter-to-datacenter communication on reliable networks, the difference between HTTP/2 and HTTP/3 is minimal -- sometimes HTTP/2 wins due to lower per-connection overhead. Benchmark your specific workload before assuming HTTP/3 is always faster.

How do I verify which HTTP version is actually being used in production?

On the server, log HttpContext.Request.Protocol in middleware. On the client, check HttpResponseMessage.Version after each call. For infrastructure-level visibility, your APM tool (Azure Monitor, Datadog, OpenTelemetry) should surface HTTP version as a dimension on request metrics -- look for http.version in OpenTelemetry semantic conventions.


Wrapping Up

HTTP/3 in .NET 10 is not experimental -- it is a well-integrated, production-ready feature that you can enable incrementally. The HttpVersionPolicy enum gives you fine-grained control over version negotiation. Kestrel's HttpProtocols enum lets you enable all three versions on a single endpoint with one line. And the Alt-Svc upgrade mechanism ensures backward compatibility with clients that do not yet support QUIC.

The path forward for most applications is straightforward: add Http3 to your Kestrel protocol list, set DefaultVersionPolicy to RequestVersionOrLower on your clients, and let the protocol do the work. You get the performance benefits where the network and infrastructure support them, and graceful fallback everywhere else.


Disclaimer: Yes! This was written by AI based on input and direction from yours truly, Nick Cosentino, for Dev Leader. I have ensured that it represents my thoughts and perspectives on HTTP/3 in .NET 10.

HttpClient Resilience in .NET 10: Timeout, Retry, and Circuit Breaker with Microsoft.Extensions.Http.Resilience

A complete guide to HttpClient retry policy C# patterns in .NET 10 using Microsoft.Extensions.Http.Resilience -- retries, circuit breakers, timeouts, and more.

Weekly Recap: EF Core, HttpClient, and C# Design Patterns [Jun 2026]

This week goes deep on Entity Framework Core -- relationships, LINQ querying, performance tuning, testing strategies, and how it compares to Dapper -- plus HttpClient and IHttpClientFactory patterns in .NET. There's also a full run of C# design patterns covering Visitor, Interpreter, and Memento, alongside new videos on debugging, AI in your development workflow, and engineering leadership.

HttpClient in C#: The Complete Guide for .NET Developers

The definitive guide to HttpClient in C# and .NET 10 -- covering correct usage patterns, IHttpClientFactory, DNS pitfalls, resilience, streaming, HTTP/3, testing, and observability. No other article you'll need.

An error has occurred. This application may no longer respond until reloaded. Reload