Microsoft has shipped Preview 1 of .NET 8, and this one matters more than a typical preview because .NET 8 is a long term support (LTS) release. That means whatever ships in November 2023 is what most enterprise teams will be running for the next three years. Preview builds arrive monthly from here until the general availability release at .NET Conf, so this is a good moment to start tracking what is actually landing versus what is just being talked about.
This preview does not bring dramatic new APIs. What it brings instead is a set of practical changes to Native AOT, container images, the SDK, and a handful of core library types that most teams will end up using without even thinking about it. I have picked out the changes that are worth planning around now, rather than reproducing the entire release note.
Native AOT keeps getting smaller
.NET 7 introduced Native AOT for console applications, letting you publish a single self-contained binary with no separate runtime dependency. Preview 1 focuses mostly on trimming the size of that binary further, and the numbers are worth noting if you have been avoiding AOT because of binary size concerns.
- Linux x64 (with -p:StripSymbols=true): 3.76 MB on .NET 7, down to 1.84 MB on .NET 8 Preview 1
- Windows x64: 2.85 MB on .NET 7, down to 1.77 MB on .NET 8 Preview 1
That is roughly a 50 percent reduction for a plain Hello World console app on Linux. If you run AOT compiled services on constrained infrastructure, such as serverless functions or edge devices, this size reduction directly affects cold start time and image pull time, not just disk usage. The trade off to keep in mind is still the same one from .NET 7: AOT removes reflection based features by default, so any library that depends on runtime reflection, dynamic code generation, or certain serialization patterns needs to be verified against AOT constraints before you commit a service to it. AOT is not a drop in replacement for every app type yet. It is currently aimed at console applications and is being expanded to cover more scenarios through the .NET 8 cycle, so check what your specific workload needs before switching your build pipeline over.
Container images move to non root by default
This is the change I would flag first if you run ASP.NET Core or worker services in containers. Starting with Preview 1, every .NET container image Microsoft publishes is non root capable, and running as non root is now a one line change in your Dockerfile.
USER app
Add that single line to your Dockerfile and the container process runs as the built in app user instead of root. You can also pass -u app at container run time if you want to control this outside the Dockerfile. Running containers as root is a bad habit that most teams keep out of convenience because configuring a dedicated non root user per image is tedious, and this removes that excuse. If a container escape vulnerability is ever found in your base image, a non root process limits what an attacker can actually do on the host.
There is a real breaking change bundled with this: the default port has moved from 80 to 8080, because port 80 is a privileged port that a non root process cannot bind to. If your deployment manifests, load balancer health checks, or Kubernetes service definitions hard code port 80 for a .NET container, they will silently stop routing traffic after you upgrade the base image. Grep your Dockerfiles and Helm charts for EXPOSE 80 and any hard coded port 80 references before you touch the base image tag. Alongside this, the default Linux distro for container images moves to Debian 12 (Bookworm), and preview container image tags now use an explicit 8.0-preview suffix instead of 8.0, so your CI pipeline pulling the 8.0 tag will not accidentally grab a preview build.
dotnet publish and dotnet pack now default to Release
This is a small change with a large blast radius if your build scripts are not careful. Historically, dotnet publish and dotnet pack would use whatever configuration was already the default, which in most project setups meant Debug unless you explicitly passed -c Release. From .NET 8 onward, both commands produce Release assets by default.
/app# dotnet new console
/app# dotnet build
app -> /app/bin/Debug/net8.0/app.dll
/app# dotnet publish
app -> /app/bin/Release/net8.0/app.dll
app -> /app/bin/Release/net8.0/publish/
/app# dotnet publish -p:PublishRelease=false
app -> /app/bin/Debug/net8.0/app.dll
app -> /app/bin/Debug/net8.0/publish/
Notice that dotnet build still defaults to Debug, only publish and pack change behaviour. This is controlled by two new MSBuild properties, PublishRelease and PackRelease, both of which default to true. If your CI pipeline was relying on dotnet publish implicitly producing Debug binaries, or if you have automation that inspects the bin/Debug folder after a publish step, that automation will break quietly rather than with an obvious error. The same properties already exist in .NET 7 starting with the 7.0.200 SDK, but they are opt in there. Worth testing your build pipeline against a Preview 1 SDK now rather than discovering this in November.
New library types built specifically for hot paths
A few additions in this preview exist purely to remove repeated setup cost from code that runs frequently. None of them are complicated, but each solves a specific and common performance mistake.
FrozenDictionary and FrozenSet, in the new System.Collections.Frozen namespace, are collections you build once and never mutate again. Because the collection knows it will never change after construction, it can spend extra time upfront organizing its internal data to make every subsequent lookup faster. This is exactly the shape of a configuration cache or lookup table loaded once at startup and read for the entire lifetime of a service.
private static readonly FrozenDictionary<string, bool> s_configurationData =
LoadConfigurationData().ToFrozenDictionary(optimizeForReads: true);
...
if (s_configurationData.TryGetValue(key, out bool setting) && setting)
{
Process();
}
Do not reach for FrozenDictionary as a default replacement for Dictionary everywhere. The construction cost is genuinely higher, so it only pays off when the collection is built rarely and read very often, which is the common case for static configuration but not for short lived data structures.
IndexOfAnyValues<T> solves a similar problem for character or byte searches. If you have ever written a static readonly char array purely so you can call IndexOfAny against it repeatedly, this new type precomputes the internal search state once so every call after that is faster, without you having to hand roll any vectorized search logic.
private static readonly IndexOfAnyValues<char> s_chars =
IndexOfAnyValues.Create("-.0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz");
...
int i = str.AsSpan().IndexOfAny(s_chars);
CompositeFormat follows the same idea for string formatting. C# 10 already improved interpolated string performance when the format string is known at compile time, but that does not help when your format string comes from a resource file or a database at runtime. CompositeFormat.Parse lets you pay the parsing cost once and reuse the parsed format on every subsequent call to string.Format.
private static readonly CompositeFormat s_rangeMessage =
CompositeFormat.Parse(LoadRangeMessageResource());
...
static string GetMessage(int min, int max) =>
string.Format(CultureInfo.InvariantCulture, s_rangeMessage, min, max);
All three types follow the same underlying pattern: shift repeated work from every call site to a single one time setup step. If you are doing a performance pass on a service, these are worth grepping for as replacements wherever you see a static readonly array feeding IndexOfAny, or a hot path calling string.Format with the same format string every time.
System.Text.Json gets a few overdue conveniences
The JSON improvements in this preview are aimed squarely at teams using the source generator with ASP.NET Core in Native AOT applications, but they are useful even if you are not touching AOT at all.
The most immediately useful one is JsonUnmappedMemberHandling, which lets you control what happens when incoming JSON has properties that do not exist on your target type. By default, System.Text.Json silently ignores unknown properties, which is convenient but can hide real bugs, such as a client sending a field under the wrong casing or a typo in a property name that never gets caught.
JsonSerializer.Deserialize<MyPoco>("""{"Id" : 42, "AnotherId" : -1 }""");
// JsonException: The JSON property 'AnotherId' could not be mapped to any .NET member
[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)]
public class MyPoco
{
public int Id { get; set; }
}
Setting Disallow on a POCO throws instead of ignoring the extra field, which is a good default for internal service to service contracts where you actually want to catch schema drift early, but probably too strict for a public API where clients may reasonably send extra fields you do not care about. Use it selectively rather than globally.
The library also ships built in naming policies for snake_case and kebab-case now, alongside the existing camelCase policy. If you have been writing a custom IJsonNamingPolicy implementation just to talk to a Python or Ruby backend that uses snake_case, you can delete that class.
var options = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower };
JsonSerializer.Serialize(new { PropertyName = "value" }, options);
// { "property_name" : "value" }
One more small but genuinely useful addition is JsonSerializerOptions.MakeReadOnly(). Previously, a JsonSerializerOptions instance would only freeze implicitly the first time you passed it to JsonSerializer, which made it easy to accidentally mutate shared options after they were already in use somewhere else. Calling MakeReadOnly() explicitly before exposing the instance removes that class of bug entirely.
What I would watch as this heads toward GA
None of the changes in Preview 1 are individually dramatic, which is normal for a first preview of an LTS release. The pattern worth noticing is that Microsoft is using .NET 8 to clean up defaults that were always technically configurable but wrong out of the box: containers running as root, publish producing Debug binaries, and JSON silently swallowing unmapped fields. If your project relies on any of those old defaults, this is the release where they change under you.
Practically, that means the two things worth testing early are your container port configuration and any automation that depends on dotnet publish producing Debug output. Everything else here, the AOT size reduction, the new performance types, and the JSON conveniences, are additive and safe to adopt gradually as later previews stabilize them.
Leave a Reply