.NET 11 shipped today. The announcement runs to several thousand words across the runtime, libraries, SDK, C# 15, ASP.NET Core, EF Core and MAUI, and most of it will never reach an architecture decision record. A faster Math.BigMul does not change a design. A higher minimum CPU does. So does a cast that returns a different number than it did last week.
This article sorts the release by consequence. It takes four questions an architect has to answer before an upgrade is scheduled, and answers each one with something that was run: the same program built for .NET 10 and .NET 11 on one machine, the same async code built with and without the new Runtime Async, the new library APIs on a megabyte of synthetic clinical data, and a script that tells you whether a host can start a .NET 11 process at all.
The support window is the first design input
.NET 11 is a Standard Term Support release, and STS now means two years of support. Long Term Support and Standard Term Support releases get the same quality bar and the same servicing; the only difference is how long it lasts. Two other dates were set by the same announcement: .NET 8 and .NET 9 both reached end of support today.
| Version | Type | Position on 10 November 2026 |
|---|---|---|
| .NET 8 | LTS | End of support. No further security patches |
| .NET 9 | STS | End of support. No further security patches |
| .NET 10 | LTS | Supported. One year of servicing behind it |
| .NET 11 | STS | Supported for two years from today |
The practical reading for an estate: anything still on .NET 8 or 9 is now carrying unpatched-runtime risk and has to move, and the choice of destination is closer than it used to be. With a two-year STS window, “pick the LTS because it lasts longer” is a much weaker argument than it was when STS meant eighteen months. The decision turns on the four questions below. Check the published support policy for the exact end dates before you commit a roadmap to them.

Question 1: will it start?
.NET 11 raises the minimum instruction set. On x86-64 the baseline moves from x86-64-v1 to x86-64-v2, which adds CX16, POPCNT, SSE3, SSSE3, SSE4.1 and SSE4.2. A process on a CPU without them does not degrade. It exits at start-up with a message that the CPU is missing one or more baseline instruction sets. The precompiled code in the framework now targets x86-64-v3 on Windows and Linux (AVX, AVX2, BMI1, BMI2, F16C, FMA, LZCNT, MOVBE), so a host between the two levels starts and then spends more time in the JIT. On Arm64, Windows now requires LSE; Linux devices such as the Raspberry Pi stay supported.
Physical servers bought in the last decade meet v2. The places this bites are less obvious:
- Virtual machines with a generic CPU model. A hypervisor configured to present a lowest-common-denominator CPU, for live migration across mixed hosts, can hide instructions the physical CPU has. QEMU’s
qemu64andkvm64models are below v2. - Old on-premises estates: hospital and public-sector data centres where a virtualisation cluster has outlived two procurement cycles.
- Edge and embedded x86: low-power Atom-class devices in clinical or industrial settings.
- Emulated build agents, where the emulator’s default CPU is old.
This is a question to settle with data from the fleet, before any code changes. The script below reads the CPU flags of a Linux host, or a flag list collected from elsewhere, and exits non-zero when .NET 11 would not start.
"""Will this x86-64 host start a .NET 11 process?
.NET 11 raises the minimum x86-64 instruction set from x86-64-v1 to x86-64-v2, and its precompiled
(ReadyToRun) code targets x86-64-v3 on Windows and Linux. A host below v2 fails at start-up; a host
between v2 and v3 starts, and re-compiles more code with the JIT.
python check_cpu_baseline.py # this Linux host, from /proc/cpuinfo
python check_cpu_baseline.py --flags "fpu sse2 ..." # a flag list collected from another host
lscpu | grep Flags | python check_cpu_baseline.py --stdin
Exit code 0: v2 or better. 1: below v2, .NET 11 will not start. 2: could not tell.
"""
from __future__ import annotations
import argparse
import json
import sys
# Names as Linux prints them in /proc/cpuinfo. SSE3 is 'pni'; LZCNT is 'abm'.
V2 = {"cx16": "CX16", "popcnt": "POPCNT", "pni": "SSE3", "ssse3": "SSSE3", "sse4_1": "SSE4.1", "sse4_2": "SSE4.2"}
V3 = {"avx": "AVX", "avx2": "AVX2", "bmi1": "BMI1", "bmi2": "BMI2", "f16c": "F16C", "fma": "FMA", "abm": "LZCNT", "movbe": "MOVBE"}
def assess(flags: set[str]) -> dict:
missing_v2 = sorted(name for flag, name in V2.items() if flag not in flags)
missing_v3 = sorted(name for flag, name in V3.items() if flag not in flags)
level = "below x86-64-v2" if missing_v2 else ("x86-64-v2" if missing_v3 else "x86-64-v3")
return {
"level": level,
"startsDotnet11": not missing_v2,
"usesPrecompiledCode": not missing_v2 and not missing_v3,
"missingForV2": missing_v2,
"missingForV3": missing_v3,
}
def flags_from_cpuinfo(text: str) -> set[str]:
for line in text.splitlines():
if line.lower().startswith("flags"):
return set(line.split(":", 1)[1].split())
raise ValueError("no 'flags' line found")
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--flags", help="space-separated CPU flags")
parser.add_argument("--stdin", action="store_true", help="read a 'Flags:' line from standard input")
args = parser.parse_args()
try:
if args.flags is not None:
flags = set(args.flags.split())
elif args.stdin:
flags = flags_from_cpuinfo(sys.stdin.read())
else:
with open("/proc/cpuinfo", encoding="utf-8") as handle:
flags = flags_from_cpuinfo(handle.read())
except (OSError, ValueError) as error:
print(f"Could not read CPU flags: {error}", file=sys.stderr)
return 2
result = assess(flags)
print(json.dumps(result, indent=2))
return 0 if result["startsDotnet11"] else 1
if __name__ == "__main__":
sys.exit(main())
On the GitHub-hosted runner used for this article it reported x86-64-v3 with nothing missing. Its seven unit tests include a host without SSE4.2 and a host without POPCNT, both of which it refuses. Run it through your configuration management tool across every host and VM template that runs .NET, and treat any exit code 1 as a blocker with an owner.
The base image is not the constraint. A .NET 11 container image starts or fails according to the CPU the node exposes to it. In Kubernetes, a mixed node pool can schedule the same pod onto a node where it runs and a node where it crashes at start. Check node CPU models, and VM sizes in older cloud families, before the image is rebuilt.
Question 2: will it behave the same?
Release notes list breaking changes. They do not tell you which ones your code depends on. The cheapest way to find out is to run the same code on both runtimes and compare. The program below targets net10.0 and net11.0 from one project, takes its inputs at run time so the compiler cannot fold the answers, and records the results.
// One program, built for net10.0 and net11.0, run on the same machine. It records behaviour that the
// .NET 11 release notes say has changed, and a few timings, so an upgrade is decided on evidence.
// Usage: dotnet run -c Release -f net11.0 -- <report.json>
using System.IO.Compression;
using System.Numerics;
using System.Runtime.InteropServices;
using System.Runtime.Intrinsics.X86;
using System.Text.Json;
using System.Text.Json.Nodes;
using Dotnet11Sample;
// Values arrive at run time so the compiler cannot fold the conversions below.
double big = double.Parse(Environment.GetEnvironmentVariable("UPGRADE_CHECK_BIG") ?? "300.7", System.Globalization.CultureInfo.InvariantCulture);
double negative = -big;
double huge = big * 1e9;
static int EmptyLength(Func<Stream, Stream> wrap)
{
using MemoryStream target = new();
wrap(target).Dispose();
return target.ToArray().Length;
}
JsonObject behaviour = new()
{
["empty gzip stream, bytes written"] = EmptyLength(s => new GZipStream(s, CompressionLevel.Optimal, leaveOpen: true)),
["empty deflate stream, bytes written"] = EmptyLength(s => new DeflateStream(s, CompressionLevel.Optimal, leaveOpen: true)),
["empty zlib stream, bytes written"] = EmptyLength(s => new ZLibStream(s, CompressionLevel.Optimal, leaveOpen: true)),
["unchecked (byte)300.7"] = unchecked((byte)big),
["unchecked (sbyte)300.7"] = unchecked((sbyte)big),
["unchecked (byte)-300.7"] = unchecked((byte)negative),
["unchecked (short)300.7e9"] = unchecked((short)huge),
["unchecked (ushort)-300.7"] = unchecked((ushort)negative),
["unchecked (int)300.7e9"] = unchecked((int)huge),
["(double)BigInteger 2^53+1"] = ((double)(BigInteger.Pow(2, 53) + 1)).ToString("R", System.Globalization.CultureInfo.InvariantCulture),
["(decimal)BigInteger 10^28+5 / 10"] = ((decimal)(BigInteger.Pow(10, 28) + 5) / 10m).ToString(System.Globalization.CultureInfo.InvariantCulture),
["(BigInteger)2.5m"] = ((BigInteger)2.5m).ToString(),
["(BigInteger)-2.5m"] = ((BigInteger)(-2.5m)).ToString(),
["(decimal)0.1f"] = ((decimal)0.1f).ToString(System.Globalization.CultureInfo.InvariantCulture),
["(decimal)1e-7d * 3"] = ((decimal)(1e-7 * 3)).ToString(System.Globalization.CultureInfo.InvariantCulture),
};
JsonObject cpu = new()
{
["architecture"] = RuntimeInformation.ProcessArchitecture.ToString(),
["x86-64-v2: SSE4.2"] = Sse42.IsSupported, ["x86-64-v2: POPCNT"] = Popcnt.IsSupported, ["x86-64-v2: SSSE3"] = Ssse3.IsSupported,
["x86-64-v3: AVX2"] = Avx2.IsSupported, ["x86-64-v3: BMI2"] = Bmi2.IsSupported, ["x86-64-v3: FMA"] = Fma.IsSupported,
["x86-64-v3: LZCNT"] = Lzcnt.IsSupported,
};
byte[] payload = Payload.JsonLines(3000);
string[] lines = System.Text.Encoding.UTF8.GetString(payload).Split('\n', StringSplitOptions.RemoveEmptyEntries);
IReadOnlyList<Payload.Reading> readings = Payload.Readings(3000);
static async Task<int> Leaf(int value)
{
await Task.CompletedTask;
return value + 1;
}
static async Task<int> Chain(int value) => await Leaf(value) + await Leaf(value);
JsonObject timings = new()
{
["parse 3,000 JSON documents"] = Measure.MedianMilliseconds(() =>
{
long sum = 0;
foreach (string line in lines)
{
using JsonDocument document = JsonDocument.Parse(line);
sum += document.RootElement.GetProperty("valueQuantity").GetProperty("value").GetInt32();
}
GC.KeepAlive(sum);
}),
["serialize 3,000 records"] = Measure.MedianMilliseconds(() => GC.KeepAlive(JsonSerializer.SerializeToUtf8Bytes(readings))),
["gzip Optimal, 1 MB of JSON Lines"] = Measure.MedianMilliseconds(() =>
{
using MemoryStream target = new();
using (GZipStream stream = new(target, CompressionLevel.Optimal, leaveOpen: true))
{
stream.Write(payload);
}
}),
["group and average by patient"] = Measure.MedianMilliseconds(() =>
GC.KeepAlive(readings.GroupBy(r => r.Patient).Select(g => (g.Key, g.Average(r => r.BeatsPerMinute))).OrderBy(x => x.Key).ToList())),
["500,000 awaits of completed tasks"] = Measure.MedianMilliseconds(() =>
{
long sum = 0;
for (int i = 0; i < 250_000; i++)
{
sum += Chain(i).GetAwaiter().GetResult();
}
GC.KeepAlive(sum);
}),
};
JsonObject report = new()
{
["runtime"] = RuntimeInformation.FrameworkDescription, ["os"] = RuntimeInformation.OSDescription,
["behaviour"] = behaviour, ["cpu"] = cpu, ["timingsMs"] = timings,
};
string json = report.ToJsonString(new JsonSerializerOptions { WriteIndented = true });
Console.WriteLine(json);
if (args.Length > 0)
{
File.WriteAllText(args[0], json);
}
Eight of fifteen probes gave a different answer on .NET 11:
| Probe | .NET 10.0.15 | .NET 11.0.0 |
|---|---|---|
Empty GZipStream, bytes written | 0 | 20 |
Empty DeflateStream, bytes written | 0 | 2 |
Empty ZLibStream, bytes written | 0 | 8 |
unchecked((byte)300.7) | 44 | 255 |
unchecked((sbyte)300.7) | 44 | 127 |
unchecked((byte)-300.7) | 212 | 0 |
unchecked((short)300.7e9) | -1 | 32767 |
unchecked((ushort)-300.7) | 65236 | 0 |
Two families of change are visible, and both are documented.
Floating-point to small-integer conversions now saturate. An unchecked cast from double or float to byte, sbyte, short or ushort used to wrap; it now clamps to the bounds of the target type. The new behaviour is the more defensible one, and it is also a silent change in computed values. Code at risk is anything that packs measurements into narrow fields: device and sensor protocols, image and signal processing, hand-written binary serialisers, hashing and checksum routines that leaned on wrap-around. In a clinical system that list includes waveform and device-integration code, where a value that changes without an exception is the worst kind of defect. The cast to int gave the same result on both runtimes.
Compression streams always write their framing. A GZipStream that was opened and closed without a write used to produce zero bytes. It now produces a valid, empty gzip member of 20 bytes. Code that treats a zero-length file as “nothing was exported”, tests that assert an empty output, and storage lifecycle rules keyed on object size are the things to look for.
The other seven probes agreed, including conversions between BigInteger, decimal and double. The release notes say some of those conversions are now correctly rounded; the values chosen here did not exercise a difference, which is a statement about these probes and not about the change. Add probes from your own domain. The value of the harness is that it is thirty lines to extend.
Multi-targeting one test project to both frameworks for the length of the migration turns “we think nothing changed” into a failing test. Remove the old target when the last service is across.
Question 3: what can be deleted?
The library additions that matter to a design are the ones that let you remove something: a NuGet dependency, a wrapper class, a page of plumbing. Five of them were exercised on a 1.38 MB payload of 3,000 synthetic FHIR observations as JSON Lines.
// Exercises the .NET 11 library additions an architect is most likely to design around, on synthetic data,
// and writes what happened to a JSON report. Usage: dotnet run -c Release -- <report.json>
using System.Diagnostics;
using System.IO.Compression;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using Dotnet11Sample;
JsonObject report = new() { ["runtime"] = System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription };
int failed = 0;
void Check(string name, bool condition)
{
failed += condition ? 0 : 1;
Console.WriteLine($"{(condition ? "ok " : "FAIL")} {name}");
}
// 1. Zstandard beside gzip and Brotli, on the same payload.
byte[] payload = Payload.JsonLines(3000);
JsonArray compression = [];
(string Name, Func<Stream, CompressionLevel, Stream> Compress, Func<Stream, Stream> Decompress)[] codecs =
[
("gzip", (s, level) => new GZipStream(s, level, leaveOpen: true), s => new GZipStream(s, CompressionMode.Decompress)),
("brotli", (s, level) => new BrotliStream(s, level, leaveOpen: true), s => new BrotliStream(s, CompressionMode.Decompress)),
("zstandard", (s, level) => new ZstandardStream(s, level, leaveOpen: true), s => new ZstandardStream(s, CompressionMode.Decompress)),
];
foreach ((string name, Func<Stream, CompressionLevel, Stream> compress, Func<Stream, Stream> decompress) in codecs)
{
foreach (CompressionLevel level in new[] { CompressionLevel.Fastest, CompressionLevel.Optimal, CompressionLevel.SmallestSize })
{
byte[] Pack()
{
using MemoryStream target = new();
using (Stream stream = compress(target, level))
{
stream.Write(payload);
}
return target.ToArray();
}
byte[] packed = Pack();
byte[] Unpack()
{
using MemoryStream target = new();
using (Stream stream = decompress(new MemoryStream(packed)))
{
stream.CopyTo(target);
}
return target.ToArray();
}
Check($"{name} {level}: round trip", Unpack().AsSpan().SequenceEqual(payload));
compression.Add(new JsonObject
{
["codec"] = name, ["level"] = level.ToString(), ["bytes"] = packed.Length,
["ratio"] = Math.Round((double)payload.Length / packed.Length, 2),
["compressMs"] = Measure.MedianMilliseconds(() => Pack()),
["decompressMs"] = Measure.MedianMilliseconds(() => Unpack()),
});
}
}
report["compression"] = new JsonObject { ["payloadBytes"] = payload.Length, ["rows"] = compression };
// 2. FullJoin: reconcile two sides and keep what is missing from either.
string[] source = ["obs-1", "obs-2", "obs-3", "obs-5"];
string[] target = ["obs-2", "obs-3", "obs-4"];
List<(string? Source, string? Target)> joined = [.. source.FullJoin(target, s => s, t => t, StringComparer.Ordinal)];
string[] onlyInSource = [.. joined.Where(j => j.Target is null).Select(j => j.Source!)];
string[] onlyInTarget = [.. joined.Where(j => j.Source is null).Select(j => j.Target!)];
Check("FullJoin: finds what is only in the source", onlyInSource.SequenceEqual(["obs-1", "obs-5"]));
Check("FullJoin: finds what is only in the target", onlyInTarget.SequenceEqual(["obs-4"]));
report["fullJoin"] = new JsonObject
{
["rows"] = joined.Count, ["matched"] = joined.Count(j => j.Source is not null && j.Target is not null),
["onlyInSource"] = new JsonArray([.. onlyInSource.Select(s => (JsonNode)s)]),
["onlyInTarget"] = new JsonArray([.. onlyInTarget.Select(s => (JsonNode)s)]),
};
// 3. JSON Lines from an async sequence, without buffering the whole set.
async IAsyncEnumerable<Payload.Reading> Stream(int count)
{
foreach (Payload.Reading reading in Payload.Readings(count))
{
await Task.Yield();
yield return reading;
}
}
using MemoryStream lines = new();
await JsonSerializer.SerializeAsyncEnumerable(lines, Stream(100), topLevelValues: true, JsonSerializerOptions.Web, CancellationToken.None);
string[] written = Encoding.UTF8.GetString(lines.ToArray()).Split('\n', StringSplitOptions.RemoveEmptyEntries);
Check("JSON Lines: one document per line", written.Length == 100 && written.All(l => JsonNode.Parse(l)!["beatsPerMinute"] is not null));
report["jsonLines"] = new JsonObject { ["lines"] = written.Length, ["first"] = written[0] };
// 4. Run a process and capture its output in one call.
ProcessTextOutput version = await Process.RunAndCaptureTextAsync("dotnet", ["--version"], CancellationToken.None);
Check("Process: exit status and output captured", version.ExitStatus.ExitCode == 0 && version.StandardOutput.Trim().Length > 0);
report["process"] = new JsonObject { ["command"] = "dotnet --version", ["exitCode"] = version.ExitStatus.ExitCode, ["output"] = version.StandardOutput.Trim() };
// 5. Read a string as a stream without copying it into a byte array first.
string settings = """{"service":"coding","limits":{"requestsPerMinute":120}}""";
using StringStream settingsStream = new(settings, Encoding.UTF8);
using JsonDocument parsed = await JsonDocument.ParseAsync(settingsStream);
Check("StringStream: parsed as a stream", parsed.RootElement.GetProperty("limits").GetProperty("requestsPerMinute").GetInt32() == 120);
// It is a forward-only reader: asking for Length throws NotSupportedException.
Check("StringStream: read-only and not seekable", settingsStream is { CanRead: true, CanSeek: false, CanWrite: false });
report["stringStream"] = new JsonObject { ["canSeek"] = settingsStream.CanSeek, ["characters"] = settings.Length };
if (args.Length > 0)
{
File.WriteAllText(args[0], report.ToJsonString(new JsonSerializerOptions { WriteIndented = true }));
}
Console.WriteLine(failed == 0 ? "\nAll checks passed" : $"\n{failed} check(s) failed");
return failed == 0 ? 0 : 1;
| Addition | What it can retire | Observed |
|---|---|---|
ZstandardStream in System.IO.Compression | A third-party Zstandard package and its native binaries | Round trip correct at all three levels |
Enumerable.FullJoin | Hand-written two-pass reconciliation, or two left joins and a union | 5 rows from 4 and 3 inputs: 2 matched, 2 only in source, 1 only in target |
JsonSerializer.SerializeAsyncEnumerable with topLevelValues | A custom NDJSON writer for exports and bulk data | 100 records, one document per line |
Process.RunAndCaptureTextAsync | The ProcessStartInfo, redirect, read-both-streams, wait pattern | Exit code 0 and 11.0.100 captured in one call |
StringStream | Encoding a string to a byte array to hand it to a stream API | Parsed as a stream; read-only and not seekable |
Zstandard is the one with architectural weight, because compression choice shows up in storage cost, transfer time and CPU. On this payload:
| Codec and level | Compressed bytes | Ratio | Compress, ms | Decompress, ms |
|---|---|---|---|---|
| gzip Fastest | 63,654 | 21.7 | 0.67 | 1.97 |
| gzip Optimal | 41,052 | 33.6 | 4.16 | 1.64 |
| gzip SmallestSize | 38,613 | 35.8 | 6.98 | 1.71 |
| Brotli Fastest | 39,039 | 35.4 | 0.88 | 1.04 |
| Brotli Optimal | 37,482 | 36.8 | 3.81 | 1.25 |
| Brotli SmallestSize | 20,420 | 67.6 | 2,471.41 | 2.12 |
| Zstandard Fastest | 34,421 | 40.1 | 0.48 | 1.32 |
| Zstandard Optimal | 40,717 | 33.9 | 0.64 | 1.82 |
| Zstandard SmallestSize | 25,096 | 55.0 | 1,059.72 | 2.87 |
Three things stand out, with the caution that this is one highly repetitive payload on one machine. Zstandard at Fastest produced a smaller result than gzip at Optimal in about a ninth of the time. Zstandard Optimal was larger than Zstandard Fastest on this data, which is not what the level names suggest, and is a reason to measure on your own payloads before choosing a level. And SmallestSize costs seconds per megabyte for both Brotli and Zstandard: it belongs in offline archiving, never on a request path. Before adopting Zstandard for anything that leaves the service, check that every consumer can read it. For browser-facing HTTP, gzip and Brotli remain the safe pair.
One small trap from the run: StringStream is a forward-only reader. Asking it for Length throws NotSupportedException, which the first version of this sample did. Code that sizes a buffer from the stream length needs the string length instead.
Question 4: what is still preview?
Runtime Async is the largest runtime change in this release and it is a preview. Today the C# compiler rewrites every async method into a state machine class. Runtime Async moves suspension and resumption into the runtime itself. The framework libraries are already compiled this way. Your code is not, unless a project opts in:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net11.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TieredPGO>true</TieredPGO>
</PropertyGroup>
<!-- Runtime Async is a preview feature in .NET 11. Build with -p:RuntimeAsync=on to opt in. -->
<PropertyGroup Condition="'$(RuntimeAsync)' == 'on'">
<Features>$(Features);runtime-async=on</Features>
</PropertyGroup>
</Project>
The program below is built twice from the same source, with and without the feature. It captures the live stack inside a three-level async call chain, before the first suspension and after resuming, and measures what two kinds of chain allocate.
// The same async code built twice: with compiler-generated state machines, and with Runtime Async.
// Prints the live stack inside a three-level async chain, and what the chain allocates.
// Usage: dotnet run -c Release [-p:RuntimeAsync=on] -- <label> <report.json>
using System.Diagnostics;
using System.Text.Json;
using System.Text.Json.Nodes;
string label = args.Length > 0 ? args[0] : "default";
(string[] before, string[] frames) = await Outer();
Console.WriteLine($"[{label}] {before.Length} frames in the live stack before the first suspension:");
foreach (string frame in before)
{
Console.WriteLine(" " + frame);
}
Console.WriteLine($"[{label}] {frames.Length} frames in the live stack after resuming:");
foreach (string frame in frames)
{
Console.WriteLine(" " + frame);
}
const int Iterations = 200_000;
for (int i = 0; i < 20_000; i++)
{
await CompletedChain(i);
await YieldingChain(i);
}
(double completedMs, double completedBytes) = await Run(CompletedChain);
(double yieldingMs, double yieldingBytes) = await Run(YieldingChain);
Console.WriteLine($"[{label}] chain that completes synchronously: {completedBytes:F0} bytes per call, {completedMs:F0} ms for {Iterations:N0} calls");
Console.WriteLine($"[{label}] chain that yields: {yieldingBytes:F0} bytes per call, {yieldingMs:F0} ms for {Iterations:N0} calls");
if (args.Length > 1)
{
File.WriteAllText(args[1], new JsonObject
{
["label"] = label,
["framesBeforeSuspension"] = new JsonArray([.. before.Select(f => (JsonNode)f)]),
["framesAfterResuming"] = new JsonArray([.. frames.Select(f => (JsonNode)f)]),
["completedChain"] = new JsonObject { ["bytesPerCall"] = Math.Round(completedBytes), ["ms"] = Math.Round(completedMs) },
["yieldingChain"] = new JsonObject { ["bytesPerCall"] = Math.Round(yieldingBytes), ["ms"] = Math.Round(yieldingMs) },
}.ToJsonString(new JsonSerializerOptions { WriteIndented = true }));
}
return 0;
static async Task<(double Milliseconds, double BytesPerCall)> Run(Func<int, Task<int>> chain)
{
long before = GC.GetTotalAllocatedBytes(precise: true);
long started = Stopwatch.GetTimestamp();
long total = 0;
for (int i = 0; i < Iterations; i++)
{
total += await chain(i);
}
double elapsed = Stopwatch.GetElapsedTime(started).TotalMilliseconds;
long allocated = GC.GetTotalAllocatedBytes(precise: true) - before;
GC.KeepAlive(total);
return (elapsed, (double)allocated / Iterations);
}
static async Task<(string[] Before, string[] After)> Outer() => await Middle();
static async Task<(string[] Before, string[] After)> Middle() => await Inner();
static async Task<(string[] Before, string[] After)> Inner()
{
string[] before = LiveStack();
await Task.Yield();
return (before, LiveStack());
}
static string[] LiveStack() =>
[.. new StackTrace(skipFrames: 1, fNeedFileInfo: false).GetFrames().Select(f => DiagnosticMethodInfo.Create(f) is { } m ? $"{m.DeclaringTypeName}.{m.Name}" : "?")];
static async Task<int> CompletedChain(int value) => await CompletedMiddle(value) + 1;
static async Task<int> CompletedMiddle(int value) => await CompletedInner(value) + 1;
static async Task<int> CompletedInner(int value)
{
await Task.CompletedTask;
return value;
}
static async Task<int> YieldingChain(int value) => await YieldingMiddle(value) + 1;
static async Task<int> YieldingMiddle(int value) => await YieldingInner(value) + 1;
static async Task<int> YieldingInner(int value)
{
await Task.Yield();
return value;
}
| Measurement | State machines | Runtime Async |
|---|---|---|
| Live stack frames before the first suspension | 17 | 5 |
| Live stack frames after resuming on the thread pool | 10 | 6 |
| Bytes allocated per call, chain that completes synchronously | 216 | 72 |
| Bytes allocated per call, chain that yields at the bottom | 285 | 312 |
The stack result is the one that will change daily work. With state machines, the 17 frames are the five real methods interleaved with MoveNext, AsyncMethodBuilderCore.Start and AsyncTaskMethodBuilder.Start entries. With Runtime Async the stack is Inner, Middle, Outer, Main: what the developer wrote. Profilers, dump analysis and live debugging all read better.
The allocation result is mixed, and that is worth knowing. A chain whose awaits complete synchronously allocated a third of what it did before. A chain that really suspends allocated slightly more in this test, 312 bytes against 285. The headline “lower overhead” is true of some shapes of async code and not of others.
The recommendation follows from the word preview. Build a non-production configuration with the feature on and use it for diagnosis and for measuring your own hot paths. Do not ship services on it yet: preview features can change shape between servicing releases, and the opt-in is per project, so a solution can end up with mixed behaviour. The C# 15 memory-safety rules are in the same position: they need the preview language version.

A note on performance numbers
The announcement cites hundreds of runtime and library optimisations, and they are real. What a shared build machine cannot tell you is what they are worth to your service. The comparison harness also records five timings. On one run, 500,000 awaits of completed tasks took 59 percent less time on .NET 11 than on .NET 10. On the next run of the same code, on a different runner of the same type, they took 28 percent more. JSON parsing was 25 percent faster on one run and 55 percent faster on the other. Every one of those numbers is a correct measurement of a noisy environment.
The method that works: a dedicated machine or a pinned VM size, your own workload, enough iterations for a confidence interval, and the two runtimes run alternately. Until that has been done, the honest statement to a steering group is “expected to be faster, not yet measured”.
From the release notes, not run here
- C# 15 brings union types, closed hierarchies, collection expression arguments, extension indexers and labelled
breakandcontinue. Unions change how domain models and API contracts are written; they are the subject of the next article. - ASP.NET Core 11 generates OpenAPI 3.2 documents by default. That is a contract change for anyone downstream: API gateways, client generators and governance tooling that expect 3.0 or 3.1 need checking, and earlier versions can be selected explicitly. It also adds asynchronous validation and SignalR authentication refresh without reconnecting.
- EF Core 11 requires .NET 11, removes unnecessary joins in more cases, and has its own list of breaking changes.
- Supply chain: NuGet sources can be configured with a minimum package publication age, a cheap defence against freshly published malicious versions; container publishing can produce reproducible image digests through
SOURCE_DATE_EPOCH. - Cryptography: X25519 key exchange is built in; composite ML-KEM and HPKE APIs are marked experimental.
- Operations: in-process crash reports with the managed stack on Linux and macOS, enabled with
DOTNET_EnableCrashReport=1; OpenTelemetry metrics forMemoryCache.
Security and compliance
The security argument for moving is the support table, not the feature list. A service on .NET 8 or 9 stops receiving runtime security patches from today, and for a regulated workload that is a finding waiting to be written. Three points for the plan. An unsupported runtime in a system that processes personal or clinical data is a risk-register entry with a date on it, whatever the workload. The behaviour changes in question 2 are a data-integrity matter and belong in the test evidence for the upgrade, not just in the developer’s notes. And preview features have no place in a validated or safety-assessed system: record explicitly that Runtime Async and the preview language version are off in production builds.
Cost
The cost of this upgrade sits in three places. The first is the fleet check and whatever it finds: a VM CPU model is a configuration change, an old cluster is a capital conversation. The second is regression testing proportionate to how much numeric and binary handling the system does. The third is the dependency sweep, because EF Core 11 and other packages move with the framework. Against that, the saving is the dependencies and plumbing that question 3 lets you delete, and whatever your own measurements show for compute. Doing nothing has a cost too for anything on .NET 8 or 9, and that one grows every month.
Decision framework
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#FFF3E0','primaryTextColor':'#232F3E','primaryBorderColor':'#FF9900','lineColor':'#232F3E','clusterBkg':'#F8FAFC','clusterBorder':'#CBD5E1','edgeLabelBackground':'#FFFFFF','nodeTextColor':'#232F3E'}}}%%
flowchart TD
A[Service on .NET 8 or 9?] -->|Yes| B[Out of support: schedule the move now]
A -->|No, on .NET 10| C[Supported: move when a feature earns it]
B --> D{Do all hosts meet x86-64-v2?}
C --> D
D -->|No| E[Fix hosts or VM CPU models, or target .NET 10 meanwhile]
D -->|Yes| F[Run the comparison harness and your tests on both runtimes]
F --> G{Behaviour differences in your code paths?}
G -->|Yes| H[Fix and add regression tests, then upgrade]
G -->|No| I[Upgrade to .NET 11, preview features off]
classDef compute fill:#FFF3E0,stroke:#FF9900,stroke-width:2px,color:#232F3E
classDef store fill:#F0FFF4,stroke:#1D8102,stroke-width:2px,color:#145202
classDef danger fill:#FEF2F2,stroke:#D13212,stroke-width:2px,color:#991B1B
classDef net fill:#EBF8FF,stroke:#0073BB,stroke-width:2px,color:#004C74
class A,D,F,G compute
class B,E danger
class C,H net
class I store| Your situation | Recommendation |
|---|---|
| On .NET 8 or 9, modern hosts | Go to .NET 11 unless a dependency holds you on 10. Both destinations are supported; 11 avoids a second migration for the features you will want |
| On .NET 8 or 9, hosts below x86-64-v2 or unknown | Go to .NET 10 now to get back into support, and plan the host work separately |
| On .NET 10, no pressing need | Stay. Add the multi-target comparison to one service so the cost of moving is known |
| On .NET 10, need unions, Zstandard or the new diagnostics | Upgrade the services that benefit; leave the rest |
| Regulated or safety-assessed system | Any of the above, with the behaviour comparison in the test evidence and preview features recorded as off |
Key takeaways
- .NET 8 and .NET 9 are out of support as of today. .NET 11 is supported for two years, which narrows the old gap between STS and LTS.
- The minimum x86-64 CPU level rose. Check hosts and VM CPU models before anything else; a process below the baseline does not start.
- Unchecked floating-point casts to small integers now saturate, and empty compression streams now write framing. Both were observed on the same machine with the same code.
- Runtime Async is a preview. It turned a 17-frame live stack into 5 and cut allocations for synchronously completing chains, and it allocated slightly more for a chain that suspends.
- Timings from a shared machine did not repeat between runs. Decide correctness with behaviour probes and performance with your own workload on your own hardware.
If you have run the comparison on a real service and found a difference not listed here, leave a comment; that list is the most useful thing a migration write-up can contain.
Everything described as observed ran in GitHub Actions on ubuntu-latest with SDK 11.0.100, runtime 11.0.0 and runtime 10.0.15 (run 43277042753). The listings are the files in the samples repository. Facts about support, hardware baselines, C# 15, ASP.NET Core, EF Core, NuGet and cryptography are from Microsoft’s announcement and documentation and were not run. Not covered: Windows, Arm64, Native AOT, and any service under real load. All data is synthetic.
The complete sample for this article is on GitHub: nithinmohantk/blog-samples-working-repo/dotnet11-for-architects. Clone it with git clone https://github.com/nithinmohantk/blog-samples-working-repo.git and open the dotnet11-for-architects folder. Its README says how to run it and what has been verified.
Glossary
- STS and LTS— Standard Term Support and Long Term Support: the two .NET release types. They differ in length of support, not in quality.
- x86-64-v2, x86-64-v3— Named levels of the x86-64 instruction set. v2 adds SSE4.2 and POPCNT among others; v3 adds AVX2, BMI and FMA.
- ReadyToRun— Ahead-of-time compiled code shipped inside framework assemblies so that start-up needs less JIT work.
- Runtime Async— A .NET 11 preview feature in which the runtime, not the compiler, implements async suspension and resumption.
- State machine— The class the C# compiler generates today for each async method.
- Saturating conversion— A numeric conversion that clamps an out-of-range value to the nearest bound of the target type.
- Zstandard— A compression format designed for high speed at ratios comparable to or better than gzip.
- JSON Lines— A text format with one JSON document per line, used for bulk exports and streaming.
References & Further Reading
- → Announcing .NET 11— The release announcement, 10 November 2026
- → What’s new in .NET 11— Overview with links to each area
- → What’s new in the .NET 11 runtime— Hardware requirements, Runtime Async, JIT changes
- → What’s new in the .NET 11 libraries— Compression, process, JSON, LINQ and stream APIs
- → Breaking changes in .NET 11— The full compatibility list
- → What’s new in C# 15— Unions, closed hierarchies and the rest of the language release
- → Sample code for this article— The dotnet11-for-architects folder on GitHub
Discover more from C4: Container, Code, Cloud & Context
Subscribe to get the latest posts sent to your email.