November 2026 will see the release of .NET 11, the next STS release of .NET, which comes with C# 15. Let me go through the best 11 new features, and if you’d prefer this post in cringe YouTube video form, here it is below:
.NET 11 includes changes to C# syntax (some would say going a bit too far), ASP.NET Core request handling, and the machinery behind async methods. The eleven examples below show how those changes can change and improve code written for .NET 10.
I’ve even created a lovely sample project on GitHub that has a `net10-old` and a `net11-new` directory for each feature. The older project shows the approach available in .NET 10; the newer one demonstrates the .NET 11addition. You can directly run the samples below from the GitHub solution!
C# 15
1. Collection expression arguments
In the first example we use our list of YouTube subscribers as a demo.
A collection expression can now start with with(...) to pass arguments to the underlying constructor or factory. Capacity and comparer choices can sit inside the initializer. C# collection expression documentation.
Here the list reserves room for twice the initial subscriber count. The set treats differently capitalized greetings as equal. In .NET 10, constructing the list and adding its contents are separate operations.
.NET 10
string[] names = ["Gary", "Barry", "Nigel"];
List<string> subscribers = new(capacity: names.Length * 2);
subscribers.AddRange(names);
Console.WriteLine($"YouTube subscribers: {string.Join(", ", subscribers)}; capacity: {subscribers.Capacity}");
.NET 11
string[] names = ["Gary", "Barry", "Nigel"];
List<string> subscribers = [with(capacity: names.Length * 2), .. names];
Console.WriteLine($"YouTube subscribers: {string.Join(", ", subscribers)}; capacity: {subscribers.Capacity}");
The C# 15 version supplies the list capacity and set comparer through with(...). Both programs print:
YouTube subscribers: Gary, Barry, Nigel; capacity: 6C# is starting to look like another programming language and whilst useful I think this new stuff needs to be toned down. Being able to combine those initial values with constructor arguments is useful but let’s stop yeah.
2. Union types
Finally, union types - which we had to wait for AI to “end coding for good” to actually get. Previously switch statements would get warnings without fallback default arms as the types couldn’t be exhaustive.
C# 15 can declare the case set explicitly with public union Result(Success, Failure);. Case types convert implicitly into the union, and switch expressions receive exhaustiveness checking. C# union type documentation.
.NET 10
Result result = new Success("Thanks for subscribing on YouTube!");
Console.WriteLine(Describe(result));
static string Describe(Result result) => result switch
{
Success(var message) => message,
Failure(var code) => $"Error {code}",
_ => throw new ArgumentOutOfRangeException(nameof(result))
};
// C# 14: an inheritance hierarchy approximates a fixed set of cases.
// The compiler cannot prove these are all possible subclasses.
public abstract record Result;
public sealed record Success(string Message) : Result;
public sealed record Failure(int ErrorCode) : Result;
.NET 11
Result result = new Success("Thanks for subscribing on YouTube!");
Console.WriteLine(Describe(result));
static string Describe(Result result) => result switch
{
Success(var message) => message,
Failure(var code) => $"Error {code}"
};
public record Success(string Message);
public record Failure(int ErrorCode);
// C# 15: a value can be exactly one of the declared case types.
public union Result(Success, Failure);
Both programs print Thanks for subscribing on YouTube!. The .NET 11 switch covers the two declared case types without a fallback. Remove either arm and the compiler warns that the switch is incomplete.
The two records in the union version don’t inherit from a common result class. The union itself defines their relationship. This is useful when an API needs to return one of several outcomes without forcing those outcomes into an inheritance hierarchy.
3. Closed class hierarchies
A similar example next - we cannot be certain of all types that could inherit from a class without a closed hierarchy.
When the cases already share a base class, closed makes the set of direct descendants known to the compiler. A closed class is implicitly abstract, and only its declaring assembly can add direct descendants. The closed modifier reference.
This gate has a closed state and an open state carrying a percentage. The .NET 10 switch needs a fallback because an ordinary abstract base record doesn’t restrict its direct descendants to this assembly.
.NET 10
GateState state = new Open(75);
Console.WriteLine(Describe(state));
static string Describe(GateState state) => state switch
{
Closed => "closed",
Open(var percent) => $"{percent}% open",
_ => throw new ArgumentOutOfRangeException(nameof(state))
};
// C# 14: direct subclasses cannot be restricted to this assembly.
public abstract record class GateState;
public sealed record class Closed : GateState;
public sealed record class Open(float Percent) : GateState;
.NET 11
GateState state = new Open(75);
Console.WriteLine(Describe(state));
static string Describe(GateState state) => state switch
{
Closed => "closed",
Open(var percent) => $"{percent}% open"
};
// C# 15: only this assembly can declare direct descendants of GateState.
public closed record class GateState;
public sealed record class Closed : GateState;
public sealed record class Open(float Percent) : GateState;
Both versions print 75% open. Marking GateState as closed lets the compiler check that the switch handles both direct descendants.
The restriction applies to direct inheritance. A descendant can still allow further inheritance unless it is also sealed or closed. Here both descendants are sealed, which keeps the whole hierarchy fixed.
4. Extension indexers
Something for the enterprise archtiect to add to helpers.dll next. I’d probably just write ElementAt but if you wanted to reduce this further in your program only, you could already in C# 14 add an “At” extention method.
C# 15 goes further and allows an extension block to declare an indexer on its receiver. That gives calling code bracket syntax for a type that doesn’t declare that indexer itself. The block needs a named receiver because the indexer operates on an instance. Extension member documentation.
.NET 10
IEnumerable<int> numbers = Enumerable.Range(1, 4);
Console.WriteLine($"Third: {numbers.At(2)}");
public static class SequenceExtensions
{
public static int At(this IEnumerable<int> sequence, int index)
=> sequence.ElementAt(index);
}
.NET 11
IEnumerable<int> numbers = Enumerable.Range(1, 4);
Console.WriteLine($"Third: {numbers[2]}");
public static class SequenceIndexer
{
extension(IEnumerable<int> sequence)
{
public int this[int index] => sequence.ElementAt(index);
}
}
Both programs print Third: 3. The call changes from numbers.At(2) to numbers[2].
5. Labeled break and continue
The little booleans used only to escape an outer loop are syntactic noise I’d happily throw in the bin.
A plain break inside a nested loop exits the inner loop. If an event should stop the outer loop too, older code often stores a flag and checks it after the inner loop finishes.
C# 15 lets break and continue name their enclosing loop. A labeled break can also target an enclosing switch. Place the label directly on the construct it identifies. C# jump statement reference.
In this itinerary, a closed bridge skips the rest of the current day. Finding the goal ends the entire trip.
I’ve also called the label “barry” for funsies.
.NET 10
string[][] dailyRoutes =
[
["North", "West"],
["South", "Bridge closed", "East"],
["Central", "Goal", "Harbor"],
["Unused"]
];
bool stopAllDays = false;
for (int day = 0; day < dailyRoutes.Length; day++)
{
Console.WriteLine($"Day {day + 1}:");
bool skipRestOfDay = false;
foreach (string route in dailyRoutes[day])
{
if (route == "Bridge closed")
{
Console.WriteLine(" Bridge closed; next day.");
skipRestOfDay = true;
break;
}
if (route == "Goal")
{
Console.WriteLine(" Goal found; stop all days.");
stopAllDays = true;
break;
}
Console.WriteLine($" Visit {route}.");
}
if (stopAllDays)
break;
if (skipRestOfDay)
continue;
Console.WriteLine(" Day completed.");
}
.NET 11
string[][] dailyRoutes =
[
["North", "West"],
["South", "Bridge closed", "East"],
["Central", "Goal", "Harbor"],
["Unused"]
];
barry: for (int day = 0; day < dailyRoutes.Length; day++)
{
Console.WriteLine($"Day {day + 1}:");
foreach (string route in dailyRoutes[day])
{
if (route == "Bridge closed")
{
Console.WriteLine(" Bridge closed; next day.");
continue barry;
}
if (route == "Goal")
{
Console.WriteLine(" Goal found; stop all days.");
break barry;
}
Console.WriteLine($" Visit {route}.");
}
Console.WriteLine(" Day completed.");
}
The .NET 10 version needs skipRestOfDay and stopAllDays. The C# 15 version names the outer loop barry and jumps directly to that loop with continue barry; or break barry;.
Both programs produce:
Day 1:
Visit North.
Visit West.
Day completed.
Day 2:
Visit South.
Bridge closed; next day.
Day 3:
Visit Central.
Goal found; stop all days.
The jump states which loop it controls, so the outer-loop flag checks disappear. Bargain.
6. Extended layout support
We’re deep in native interop territory in this one for gigabrains. If you’re matching a C-style union in a native library, repeating a field offset for every member adds noise. Declaring the union layout lets the runtime handle that shared-storage arrangement.
Native interop sometimes requires two fields to occupy the same bytes. The older explicit-layout approach specifies an offset for every field. C# 15 recognizes [ExtendedLayout] and emits metadata that the .NET 11 runtime uses to lay out the type. Microsoft’s extended layout release note.
Selecting ExtendedLayoutKind.CUnion gives the following integer and float fields shared storage.
.NET 10
using System.Reflection;
using System.Runtime.InteropServices;
// The old explicit-layout approach requires an offset for every union field.
IntFloatBits bits = new() { AsInt = 0x3F800000 };
Console.WriteLine($"Integer bits: 0x{bits.AsInt:X8}");
Console.WriteLine($"Same bits as float: {bits.AsFloat:F1}");
Console.WriteLine($"Metadata layout: {typeof(IntFloatBits).Attributes & TypeAttributes.LayoutMask}");
[StructLayout(LayoutKind.Explicit)]
public struct IntFloatBits
{
[FieldOffset(0)] public int AsInt;
[FieldOffset(0)] public float AsFloat;
}
.NET 11
using System.Reflection;
using System.Runtime.InteropServices;
// C# 15 emits extended-layout metadata from the new .NET 11 attribute.
IntFloatBits bits = new() { AsInt = 0x3F800000 };
Console.WriteLine($"Integer bits: 0x{bits.AsInt:X8}");
Console.WriteLine($"Same bits as float: {bits.AsFloat:F1}");
Console.WriteLine($"Metadata layout: {typeof(IntFloatBits).Attributes & TypeAttributes.LayoutMask}");
[ExtendedLayout(ExtendedLayoutKind.CUnion)]
public struct IntFloatBits
{
public int AsInt;
public float AsFloat;
}
Both programs write the integer bit pattern 0x3F800000 and read the same bits through AsFloat as 1.0. The final line reports ExplicitLayout in .NET 10 and ExtendedLayout in .NET 11.
This is an interop representation where code must know which field contains a valid value. The type-safe union in feature 2 models a choice between cases; this C-style union overlays raw storage. Use the layout that matches the native ABI you need to call.
ASP.NET Core 11
7. ShortCircuit on MVC controllers and actions
MVC is still my favorite and I’m over the moon Microsoft are still improving it.
A controller serving a dynamic robots.txt is a useful reason to keep the routing behavior beside the action. The response below is constant so we can concentrate on which middleware runs.
ASP.NET Core already supports the .ShortCircuit() mapping convention. .NET 11 adds [ShortCircuit], allowing an MVC controller or action to declare the behavior beside its route metadata. Routing executes the endpoint immediately and skips middleware later in the pipeline. ASP.NET Core short-circuit release notes.
The endpoint below serves robots.txt. Middleware after UseRouting adds a diagnostic header when it runs. The .NET 10 version applies the convention while mapping a controller route; the .NET 11 version places the attribute on the controller.
.NET 10
using Microsoft.AspNetCore.Mvc;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
var app = builder.Build();
app.UseRouting();
app.Use(async (context, next) =>
{
context.Response.Headers["X-Later-Middleware-Ran"] = "yes";
await next(context);
});
// In .NET 10 the short-circuit convention must be attached when mapping
// endpoints. Conventional routing is used to target this MVC action.
app.MapControllerRoute(
name: "robots",
pattern: "robots.txt",
defaults: new { controller = "Robots", action = "Get" })
.ShortCircuit();
app.Run();
public sealed class RobotsController : ControllerBase
{
public IActionResult Get() => Content("User-agent: *\nDisallow:", "text/plain");
}
.NET 11
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
var app = builder.Build();
app.UseRouting();
app.Use(async (context, next) =>
{
context.Response.Headers["X-Later-Middleware-Ran"] = "yes";
await next(context);
});
app.MapControllers();
app.Run();
[ApiController]
[Route("robots.txt")]
[ShortCircuit]
public sealed class RobotsController : ControllerBase
{
[HttpGet]
public IActionResult Get() => Content("User-agent: *\nDisallow:", "text/plain");
}
With both web apps running, inspect the responses:
curl.exe -i http://localhost:5010/robots.txt
curl.exe -i http://localhost:5011/robots.txt
Both return 200 with the robots text. Neither response contains X-Later-Middleware-Ran, because routing executed the endpoint before that middleware.
Only apply short-circuiting when the endpoint can produce its response without later middleware running. If you need Authentication and CORS this can be screwed up big time so be careful.
8. Async validation for Minimal APIs
The Minimal API crowd gets something too. An email lookup can swallow a small registration handler once you add the database call and the invalid-response branch. Moving that rule onto the request model leaves the handler focused on accepting the registration.
Checking whether an email is already registered may require database I/O. In .NET 10, the handler below awaits that query and constructs its own validation response.
Minimal API validation in .NET 11 can await AsyncValidationAttribute and IAsyncValidatableObject rules before the handler executes. Register AddValidation(), then put the rule on the request model. ASP.NET Core asynchronous validation documentation.
.NET 10
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<IUserStore, DemoUserStore>();
var app = builder.Build();
// .NET 10: the handler itself must call the async rule and translate an
// invalid result into a response. Each endpoint would repeat this wiring.
app.MapPost("/registrations", async (Registration request, IUserStore users, CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(request.Email))
return Results.ValidationProblem(new Dictionary<string, string[]> { ["Email"] = ["Email is required."] });
if (await users.EmailExistsAsync(request.Email, cancellationToken))
return Results.ValidationProblem(new Dictionary<string, string[]> { ["Email"] = ["Email is already registered."] });
return Results.Ok(new { message = "Registered", request.Email });
});
app.Run();
public sealed record Registration(string Email);
public interface IUserStore
{
Task<bool> EmailExistsAsync(string email, CancellationToken cancellationToken);
}
public sealed class DemoUserStore : IUserStore
{
public async Task<bool> EmailExistsAsync(string email, CancellationToken cancellationToken)
{
await Task.Delay(50, cancellationToken); // Simulates asynchronous database I/O.
return string.Equals(email, "taken@example.com", StringComparison.OrdinalIgnoreCase);
}
}
.NET 11
using System.ComponentModel.DataAnnotations;
using Microsoft.Extensions.DependencyInjection;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<IUserStore, DemoUserStore>();
builder.Services.AddValidation();
var app = builder.Build();
// .NET 11 validates the request before this handler runs, awaiting the
// attribute's asynchronous rule and returning a validation problem on failure.
app.MapPost("/registrations", (Registration request) =>
Results.Ok(new { message = "Registered", request.Email }));
app.Run();
public sealed record Registration([property: Required, UniqueEmail] string Email);
public sealed class UniqueEmailAttribute : AsyncValidationAttribute
{
protected override ValidationResult? IsValid(object? value, ValidationContext context) =>
throw new InvalidOperationException("This attribute requires asynchronous validation.");
protected override async Task<ValidationResult?> IsValidAsync(
object? value, ValidationContext context, CancellationToken cancellationToken)
{
var users = context.GetRequiredService<IUserStore>();
if (value is string email && await users.EmailExistsAsync(email, cancellationToken))
return new ValidationResult("Email is already registered.");
return ValidationResult.Success;
}
}
public interface IUserStore
{
Task<bool> EmailExistsAsync(string email, CancellationToken cancellationToken);
}
public sealed class DemoUserStore : IUserStore
{
public async Task<bool> EmailExistsAsync(string email, CancellationToken cancellationToken)
{
await Task.Delay(50, cancellationToken); // Simulates asynchronous database I/O.
return string.Equals(email, "taken@example.com", StringComparison.OrdinalIgnoreCase);
}
}
The new handler processes successful registrations; UniqueEmailAttribute owns the asynchronous check. Its synchronous IsValid throws because this rule requires async validation. It doesn’t block a thread by waiting synchronously for the query.
The attribute gets the registered user store through the validation context using a bit of a bleak service locator anti-pattern. The asynchronous rule runs after the request is bound and before the handler executes, so a rejected email never reaches the registration handler.
9. Built-in Zstandard HTTP compression
ASP.NET Core 11 adds Zstandard, or zstd, to the default response compression and request decompression providers. The middleware registrations stay the same. You can configure zstd quality through ZstandardCompressionProviderOptions. Microsoft’s Zstandard HTTP release note.
The following apps return a text payload and echo request bodies. The .NET 10 built-in providers don’t support zstd, while the .NET 11 providers do.
.NET 10
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddResponseCompression();
builder.Services.AddRequestDecompression();
var app = builder.Build();
app.UseResponseCompression();
app.UseRequestDecompression();
// The .NET 10 built-in providers support Brotli and Gzip etc
app.MapGet("/payload", () => Results.Text(
string.Concat(Enumerable.Repeat("ASP.NET Core compression example. ", 100)),
"text/plain"));
app.MapPost("/echo", async (HttpRequest request) =>
{
using var reader = new StreamReader(request.Body);
return Results.Text(await reader.ReadToEndAsync(), "text/plain");
});
app.Run();
.NET 11
using System.IO.Compression;
using Microsoft.AspNetCore.ResponseCompression;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddResponseCompression();
builder.Services.AddRequestDecompression();
builder.Services.Configure<ZstandardCompressionProviderOptions>(options =>
{
options.CompressionOptions = new ZstandardCompressionOptions { Quality = 6 };
});
var app = builder.Build();
app.UseResponseCompression();
app.UseRequestDecompression();
// ASP.NET Core 11 includes zstd among its default response compression and
// request decompression providers. No custom provider registration is needed.
app.MapGet("/payload", () => Results.Text(
string.Concat(Enumerable.Repeat("ASP.NET Core compression example. ", 100)),
"text/plain"));
app.MapPost("/echo", async (HttpRequest request) =>
{
using var reader = new StreamReader(request.Body);
return Results.Text(await reader.ReadToEndAsync(), "text/plain");
});
app.Run();
Ask each server for zstd and inspect only the response headers:
curl.exe -s -D - -o NUL -H 'Accept-Encoding: zstd' http://localhost:5010/payload
curl.exe -s -D - -o NUL -H 'Accept-Encoding: zstd' http://localhost:5011/payload
Both responses are 200. The first has no Content-Encoding header; the second contains Content-Encoding: zstd. The client must advertise support for the encoding or .NET 11 ignores it.
10. Dynamic output-cache policies
I can see this being helpful when cache rules need to change while an app keeps running. The provider gets a policy name and can resolve it against current settings. That gives us a place to load or change the rule at runtime, rather than fixing this program’s duration at startup.
A named output-cache policy in .NET 10 can be configured at startup with AddPolicy. The first program caches a public catalog response for 30 seconds.
.NET 11 adds IOutputCachePolicyProvider. The middleware asks the provider to resolve a named policy, letting that provider consult configuration that can change while the app runs. Microsoft’s output-cache provider release note.
The second program includes the entire provider and policy implementation. It keeps the duration in memory and exposes an endpoint to change it.
.NET 10
using Microsoft.AspNetCore.OutputCaching;
var builder = WebApplication.CreateBuilder(args);
// In .NET 10, the named policy is defined when the application starts.
builder.Services.AddOutputCache(options =>
options.AddPolicy("catalog", policy =>
policy.Expire(TimeSpan.FromSeconds(30))));
var app = builder.Build();
app.UseOutputCache();
var generationCount = 0;
app.MapGet("/catalog", () => new
{
Generation = Interlocked.Increment(ref generationCount),
GeneratedAt = DateTimeOffset.UtcNow,
YouTubeSubscriberReminder = "Enjoying the demo? Subscribe for more .NET examples!"
}).CacheOutput("catalog");
app.Run();
.NET 11
using Microsoft.AspNetCore.OutputCaching;
using Microsoft.Extensions.DependencyInjection.Extensions;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<CacheDurationStore>();
builder.Services.AddOutputCache();
// The .NET 11 provider resolves the named policy when it is requested.
builder.Services.Replace(
ServiceDescriptor.Singleton<IOutputCachePolicyProvider, DynamicCachePolicyProvider>());
var app = builder.Build();
app.UseOutputCache();
var generationCount = 0;
app.MapGet("/catalog", (CacheDurationStore store) => new
{
Generation = Interlocked.Increment(ref generationCount),
GeneratedAt = DateTimeOffset.UtcNow,
CacheSeconds = store.Seconds,
YouTubeSubscriberReminder = "Enjoying the demo? Subscribe for more .NET examples!"
}).CacheOutput("catalog");
app.MapPost("/cache-seconds/{seconds:int}", async (
int seconds, CacheDurationStore store, IOutputCacheStore cache) =>
{
if (seconds is < 1 or > 120)
{
return Results.BadRequest("Choose a duration from 1 to 120 seconds.");
}
store.Seconds = seconds;
await cache.EvictByTagAsync("catalog", CancellationToken.None);
return Results.Ok(new { CacheSeconds = seconds });
});
app.Run();
sealed class CacheDurationStore
{
private int _seconds = 30;
public int Seconds
{
get => Volatile.Read(ref _seconds);
set => Volatile.Write(ref _seconds, value);
}
}
sealed class DynamicCachePolicyProvider(CacheDurationStore store) : IOutputCachePolicyProvider
{
public IReadOnlyList<IOutputCachePolicy> GetBasePolicies() => [];
public ValueTask<IOutputCachePolicy?> GetPolicyAsync(string policyName)
{
if (policyName != "catalog")
{
return ValueTask.FromResult<IOutputCachePolicy?>(null);
}
IOutputCachePolicy policy = new DynamicDurationPolicy(store.Seconds);
return ValueTask.FromResult<IOutputCachePolicy?>(policy);
}
}
sealed class DynamicDurationPolicy(int seconds) : IOutputCachePolicy
{
public ValueTask CacheRequestAsync(OutputCacheContext context, CancellationToken cancellationToken)
{
context.EnableOutputCaching = true;
context.AllowCacheLookup = true;
context.AllowCacheStorage = true;
context.AllowLocking = true;
context.Tags.Add("catalog");
context.ResponseExpirationTimeSpan = TimeSpan.FromSeconds(seconds);
return ValueTask.CompletedTask;
}
public ValueTask ServeFromCacheAsync(OutputCacheContext context, CancellationToken cancellationToken)
=> ValueTask.CompletedTask;
public ValueTask ServeResponseAsync(OutputCacheContext context, CancellationToken cancellationToken)
{
context.ResponseExpirationTimeSpan = TimeSpan.FromSeconds(seconds);
return ValueTask.CompletedTask;
}
}
The provider creates DynamicDurationPolicy from the current duration. The policy enables lookup and storage, sets the expiration, and tags the cached response as catalog.
Run these requests against the .NET 11 app:
Invoke-RestMethod http://localhost:5011/catalog
Invoke-RestMethod http://localhost:5011/catalog
Invoke-RestMethod -Method Post http://localhost:5011/cache-seconds/5
Invoke-RestMethod http://localhost:5011/catalog
The first two GETs have the same Generation while the response is cached. The POST updates the duration and evicts the tagged entry. The next GET generates a fresh response and reports CacheSeconds as 5.
Changing the duration doesn’t remove an existing entry by itself. The explicit eviction makes the update visible immediately. This program caches one public response; tenant-specific responses need cache keys that distinguish their contents.
.NET 11 runtime
11. Runtime-native async
Runtime Async V2 moves suspension and resumption into the runtime. Application code still uses async and await, but the execution method changes. One visible effect is the live stack trace where application methods appear more directly without the compiler-generated async infrastructure between them. Microsoft’s Runtime Async documentation.
These programs call OuterAsync, then MiddleAsync, then InnerAsync, which prints a live StackTrace.
.NET 10
using System.Diagnostics;
await OuterAsync();
static async Task OuterAsync()
{
await Task.CompletedTask;
await MiddleAsync();
}
static async Task MiddleAsync()
{
await Task.CompletedTask;
await InnerAsync();
}
static async Task InnerAsync()
{
await Task.CompletedTask;
Console.WriteLine("Live stack with compiler-generated async:");
Console.WriteLine(new StackTrace(fNeedFileInfo: true));
}
.NET 11
using System.Diagnostics;
await OuterAsync();
static async Task OuterAsync()
{
await Task.CompletedTask;
await MiddleAsync();
}
static async Task MiddleAsync()
{
await Task.CompletedTask;
await InnerAsync();
}
static async Task InnerAsync()
{
await Task.CompletedTask;
Console.WriteLine("Live stack with runtime-native async:");
Console.WriteLine(new StackTrace(fNeedFileInfo: true));
}
Runtime async remains an opt-in preview feature. For the .NET 11 program, use this complete project file:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net11.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<Features>$(Features);runtime-async=on</Features>
</PropertyGroup>
</Project>
Run each console app in Debug configuration:
dotnet run --project Demo10 -c Debug
dotnet run --project Demo11 -c Debug
The .NET 10 trace includes infrastructure frames such as AsyncMethodBuilderCore.Start. With runtime async enabled, the application call chain appears more directly. Build settings and JIT optimizations affect the number of frames, so compare the method chain.
A net11.0 project doesn’t also need EnablePreviewFeatures for this opt-in. Exception stack traces already receive cleanup; the code inspects a live stack to show the diagnostic change.
This should basically be a free speedup for any async heavy apps.
Closing thoughts
I think it’s time to slow down on the new syntax in C# 16. .NET 12 needs to compete with Rust etc for the attention of the AI pilled vibe coders, even within Microsoft. Fix AoT and make it competitive with Rust and Go.

