December 8th, 2025
0 reactions

.NET 10 Networking Improvements

Máňa
Software Engineer

As with every release, we publish a blog post about the new and interesting changes and additions in .NET networking space. This time, we are writing about HTTP improvements, new web sockets APIs, security changes and many distinct additions in networking primitives.

HTTP

The following section introduces improvements and additions in HTTP space. Among them are a performance optimization in WinHttpHandler, a new HTTP verb and small addition in cookies.

WinHttpHandler

One of the performance improvements in .NET 10 is an optimization of server certificate validation in WinHttpHandler. Normally, the validation is left to the native WinHTTP implementation, which is expected to do the right thing. But sometimes, user code needs more control over the process and WinHttpHandler offers ServerCertificateValidationCallback for exactly that reason. Once the user code registers the callback, WinHttpHandler skips the internal WinHTTP validation. Unfortunately, there’s no exact event raised by the native WinHTTP that would correspond to connection establishment and would also provide the server certificate for validation. As a result, the managed layer within WinHttpHandler is forced to invoke the custom ServerCertificateValidationCallback on each and every request. To avoid this, a cache of already validated certificates by server IP address was put in place. Every time a new request is being sent, WinHttpHandler skips building the whole certificate chain and invoking the custom callback if the certificate was previously validated. On top of that, each new connection clears up the cached certificate for that particular server IP to re-validate on connection recreation. To stay prudent from a security perspective, this feature (dotnet/runtime#111791) is opt-in and hidden behind an AppContext switch:

AppContext.SetSwitch("System.Net.Http.UseWinHttpCertificateCaching", true);

The following example illustrates the effect of the switch:

using System.Net.Security;

AppContext.SetSwitch("System.Net.Http.UseWinHttpCertificateCaching", true);

using var client = new HttpClient(new WinHttpHandler()
{
    ServerCertificateValidationCallback = static (req, cert, chain, errors) =>
    {
        Console.WriteLine("Server certificate validation invoked");
        return errors == SslPolicyErrors.None;
    }
});

Console.WriteLine((await client.GetAsync("https://github.com")).StatusCode);
Console.WriteLine((await client.GetAsync("https://github.com")).StatusCode);
Console.WriteLine((await client.GetAsync("https://github.com")).StatusCode);

The callback is invoked only once, with output like the following:

Server certificate validation invoked
OK
OK
OK

Whereas without the switch, the callback is invoked with each request:

Server certificate validation invoked
OK
Server certificate validation invoked
OK
Server certificate validation invoked
OK

The following graph shows the cumulative time saved with the certificate caching per increasing number of requests: Cumulative time saved with certificate caching

QUERY

Another addition is a new HTTP verb QUERY. The purpose of the verb is to allow sending details for the query in the request body while still using a safe, idempotent request. In other words, being able to execute multiple times without affecting server data. One of the use cases is when the details of the query might not fit into URI length limits and using body in GET request is not supported by the server. The verb is still in process of standardization in The HTTP QUERY Method proposal. For that reason, we have decided to postpone the full implementation of the helper methods in dotnet/runtime#113522 until the RFC is published. And we only added the string constant in dotnet/runtime#114489, which can be used this way:

using var client = new HttpClient();
var response = await client.SendAsync(new HttpRequestMessage(HttpMethod.Query, "https://api.example.com/resource"));

Cookies

A very small change was requested: making the CookieException constructor public in dotnet/runtime#95965. The change was made by a community contributor @deeprobin in dotnet/runtime#109026. CookieException can now be manually thrown to avoid unexpected visits from Cookie Monster:

throw new CookieException("🍪");

WebSockets

Working with raw WebSocket APIs means dealing with low-level details—buffering, framing, encoding, and custom wrappers to integrate with streams or channels. This complexity makes building transport layers and streaming protocols harder.

.NET 10 introduces WebSocketStream, a stream-based abstraction over WebSockets that makes reading, writing, and parsing data for text and binary protocols much simpler.

Key advantages:

  • Stream-first design: Works seamlessly with StreamReader, JsonSerializer, compressors, serializers, etc.
  • No manual plumbing: Eliminates message framing and leftover handling.
  • Supports multiple scenarios: JSON-based protocols, text-based protocols like STOMP, and binary protocols like AMQP.

Common usage patterns

  1. Read a complete JSON message (text)

    Use CreateReadableMessageStream with JsonSerializer.DeserializeAsync<T>(). You don’t need a manual receive loop or a MemoryStream buffer. The stream ends exactly at the message boundary, so DeserializeAsync completes naturally when the message is fully read.

  2. Stream a text protocol (e.g., STOMP-like, line oriented)

    Use Create to get a transport stream, and layer a StreamReader on top. You can parse line-by-line while the stream stays open across frames. This pattern leverages automatic UTF‑8 handling and keeps reads composable with standard text parsers.

  3. Write a single binary message (e.g., AMQP payload)

    Use CreateWritableMessageStream and write chunk-by-chunk; Dispose sends end-of-message (EOM) for you. The one‑message write semantics avoid manual EndOfMessage handling and make your send path look like any other stream write.

In other words,

  • Prefer CreateReadableMessageStream / CreateWritableMessageStream for message-oriented workflows.
  • For continuous protocols, use Create with appropriate leaveOpen/ownsWebSocket semantics in layered readers/writers.
  • Dispose streams to complete EOM and close gracefully. Use closeTimeout to limit how long Dispose will wait for a graceful close handshake completion.

Before vs After (JSON example)

See the snippet below for the comparison between using a raw WebSocket loop and using a WebSocketStream to read a complete JSON message.

// BEFORE: manual buffering
static async Task<AppMessage?> ReceiveJsonManualAsync(WebSocket ws, CancellationToken ct)
{
    var buffer = new byte[8192];
    using var mem = new MemoryStream();
    while (ws.State == WebSocketState.Open)
    {
        var result = await ws.ReceiveAsync(buffer, ct);
        if (result.MessageType == WebSocketMessageType.Close)
        {
            return null;
        }
        await mem.WriteAsync(buffer.AsMemory(0, result.Count), ct);
        if (result.EndOfMessage)
        {
            break;
        }
    }
    mem.Position = 0;
    return await JsonSerializer.DeserializeAsync<AppMessage>(mem, cancellationToken: ct);
}

// AFTER: message stream
static async Task<AppMessage?> ReceiveJsonAsync(WebSocket ws, CancellationToken ct)
{
    using Stream message = WebSocketStream.CreateReadableMessageStream(ws);
    return await JsonSerializer.DeserializeAsync<AppMessage>(message, cancellationToken: ct);
}

The “after” version eliminates buffering, copies, and EndOfMessage checks via the Stream abstraction.

Security

In this release, there were two changes in the networking security space that are worth calling out. One of them is a long-standing request for TLS 1.3 support on OSX. And the second one is unifying how we expose TLS cipher suite details.

Client-side TLS 1.3 on OSX

TLS 1.3 support on OSX was highly requested issue dotnet/runtime#1979 for several releases. It was a non-trivial effort because it required switching to a different set of native OSX APIs. On top of that, the new APIs combine TLS together with TCP whereas .NET exposes these two layers independently from each other as SslStream and Socket. Furthermore, the new APIs support only TLS 1.2 and TLS 1.3 and, despite other SslProtocols being deprecated, .NET still supports them. All of this combined led to a decision to expose this functionality only as an opt-in feature behind an AppContext switch (dotnet/runtime#117428). Client application can take advantage of this new support by either setting the switch in their code:

AppContext.SetSwitch("System.Net.Security.UseNetworkFramework", true);

or with an environment variable:

export DOTNET_SYSTEM_NET_SECURITY_USENETWORKFRAMEWORK=1

Note that this will switch the backend SslStream uses for client operations on OSX, so only TLS 1.3 and TLS 1.2 will be supported. But it has no effect on server-side usage of SslStream.

Negotiated Cipher Suite

SslStream provides several properties with details about negotiated cipher suite. Among them belong KeyExchangeAlgorithm, HashAlgorithm and CipherAlgorithm. However, the underlying enums for these properties weren’t updated for a while and therefore not providing accurate information. For example, mapping multiple algorithms from the same family into the single enum value, losing important information in the process. Instead of expanding the enums, we decided to obsolete them and leave only NegotiatedCipherSuite as the only source of truth (dotnet/runtime#100361). The underlying enum TlsCipherSuite follows IANA specification for TLS Cipher Suites and encompasses all the information needed. The obsoleted properties were only derived from NegotiatedCipherSuite and didn’t convey any additional information. The whole change can be examined in dotnet/runtime#105875.

On top of that, we have introduced the same NegotiatedCipherSuite property to QuicConnection in dotnet/runtime#106391. Also being the only source of truth for the negotiated TLS details for the established connection. Discussion for the API addition can be found in dotnet/runtime#70184.

Networking Primitives

This section introduces changes in System.Net namespace which contains several additions to server-sent events helpers, IP address, URI and similar types.

Server-Sent Events Formatter

In the previous release of .NET, we have added support for parsing server-sent events, mentioned in the last year’s blog post chapter Server-Sent Events Parser. This release introduces the opposite side, formatter for server-sent events (dotnet/runtime#109294). In the most simple scenario, with string typed data, the new API can be used like in the following example:

using var stream = new MemoryStream();

// Only pass in source of items and stream to serialize into.
await SseFormatter.WriteAsync(GetStringItems(), stream);

static async IAsyncEnumerable<SseItem<string>> GetStringItems()
{
    yield return new SseItem<string>("data 1");
    yield return new SseItem<string>("data 2");
    yield return new SseItem<string>("data 3");
    yield return new SseItem<string>("data 4");
}

The content of the stream for this code will look like:

data: data 1

data: data 2

data: data 3

data: data 4

And the following example illustrates the case when the data payload is not a simple string:

var stream = new MemoryStream();

// The third argument is a delegate taking in SseItem and IBufferWriter into which the item is serialized.
await SseFormatter.WriteAsync<int>(GetItems(), stream, (item, writer) =>
{
    // The data of the item should be converted to a string and that string then converted to corresponding UTF-8 bytes.
    writer.Write(Encoding.UTF8.GetBytes(item.Data.ToString()));
});

static async IAsyncEnumerable<SseItem<int>> GetItems()
{
    yield return new SseItem<int>(1) { ReconnectionInterval = TimeSpan.FromSeconds(1) };
    yield return new SseItem<int>(2);
    yield return new SseItem<int>(3);
    yield return new SseItem<int>(4);
}

For this example, the content of the stream will look like:

data: 1
retry: 1000

data: 2

data: 3

data: 4

As the examples show, this addition (dotnet/runtime#109832) introduced two overloads for writing the events:

On top of that, SseItem<T> was expanded with two new properties for writing:

Both of these fields control the behavior of the client when the connection needs to be re-established. The last parsed EventId will map to LastEventId on the parser side and eventually will be used in Last-Event-ID header if the connection is re-created. And ReconnectionInterval will map to ReconnectionInterval and will control the time before a new connection is attempted.

With all of this together, System.Net.ServerSentEvents provides a complete set of helpers for both sides of the communication and can be used to round-trip the data back and forth:

var stream = new MemoryStream();

await SseFormatter.WriteAsync<int>(GetItems(), stream, (item, writer) =>
{
    writer.Write(Encoding.UTF8.GetBytes(item.Data.ToString()));
});

stream.Seek(0, SeekOrigin.Begin);

var parser = SseParser.Create(stream, (type, data) =>
{
    var str = Encoding.UTF8.GetString(data);
    return Int32.Parse(str);
});

await foreach (var item in parser.EnumerateAsync())
{
    Console.WriteLine($"{item.EventType}: {item.Data} {item.EventId} {item.ReconnectionInterval} [{parser.LastEventId};{parser.ReconnectionInterval}]");
}

static async IAsyncEnumerable<SseItem<int>> GetItems()
{
    yield return new SseItem<int>(1) { ReconnectionInterval = TimeSpan.FromSeconds(1) };
    yield return new SseItem<int>(2) { EventId = "2" };
    yield return new SseItem<int>(3);
    yield return new SseItem<int>(4);
}

IP Address

There were two new additions for IPAddress class. The first one is a static method to validate whether a string is a valid IP address (dotnet/runtime#111282). It can be used as:

if (IPAddress.IsValid("10.0.0.1"))
{ ... }
if (IPAddress.IsValid("::1"))
{ ... }
if (IPAddress.IsValid("10.0.1"))
{ ... }
if (IPAddress.IsValidUtf8("::192.168.0.1"u8))
{ ... }
if (IPAddress.IsValidUtf8("fe80::9656:d028:8652:66b6"u8))
{ ... }

The other change follows up on a change adding IUtf8SpanFormattable in .NET 8. Both IPAddress and IPNetwork now also implement IUtf8SpanParsable<T>. The API proposal dotnet/runtime#103111 as well as the implementation itself dotnet/runtime#102144 were done by a community contributor @edwardneal.

Miscellaneous

The last changes in System.Net namespace worth mentioning are removing the length limit on Uri in dotnet/runtime#117287. The main reason for this change was to support data URI scheme (dotnet/runtime#96544) as specified in RFC 2397. Instead of linking a resource, data URI carries the data for the resource in itself. For example as base64 encoded image:

data:image/jpeg;base64,[base64 encoded data of the image]

And with the original limit of slightly under 64 KB, this was not enough for many such data URIs.

The last small change was adding a new media type for yml files (dotnet/runtime#105809). The change adds a new constant MediaTypesName.Yaml in dotnet/runtime#117211 and was done by a community contributor @martincostello.

Final Notes

It has become a tradition to publish this article every year and as in the past years, the article cannot cover all the changes that have been made. We pick things that might have a direct impact on our customers, whether it’s a new API, feature or performance improvement. And of course, many of such performance improvements are already covered by Stephen’s great article about Performance Improvements in .NET 10. We’d also like to encourage you to reach out to us in our GitHub repo in case you have any questions, requests for new features or discover any bugs. And lastly, I’d like to thank my co-author @CarnaViire for writing the WebSockets section.

Author

Máňa
Software Engineer

Máňa is a Software Engineer on the networking team for .NET libraries. She owns two aquatic turtles, swears like a sailor and drinks too much coffee.

0 comments