A console application may look simple, but deploying it to production is a different story. Even a small application can include framework dependencies, NuGet packages, configuration files, and runtime components that increase its deployment size.
This becomes important when you deploy command-line tools to containers, cloud environments, CI/CD agents, or multiple servers.
Consider a console application that runs every few minutes, processes a file, calls an API, and exits. The actual business logic might take only a few milliseconds, but application startup and deployment overhead can become a noticeable part of its execution time.
.NET provides several ways to address these problems. You can remove unused code through trimming, compile applications ahead of time using Native AOT, and configure publishing to prioritize application size or execution speed.
.NET 11 continues to build on these capabilities.
In this article, we will create a console application, examine its publishing options, and see how to make it smaller and faster without unnecessarily changing the application logic.
Why Does a Small Console Application Need Optimization?
Let's take a simple example.
You have a console application responsible for processing daily transaction files. It starts, reads a file, performs calculations, writes the results, and exits.
The application itself may contain only a few classes, but its published output can include much more than your source code.
Some of the additional components include:
- .NET runtime libraries.
- Application dependencies.
- Third-party NuGet packages.
- Native libraries.
- Configuration and supporting files.
The actual deployment size depends on the publishing model, target operating system, architecture, and dependencies.
There are also performance considerations.
If the application starts and exits frequently, startup time matters. If it runs continuously, steady-state execution performance may be more important.
This is why optimization should begin with understanding how the application is used.
Step 1: Create a Basic .NET 11 Console Application
First, create a new console application.
dotnet new console -n FastConsoleApp -f net11.0
cd FastConsoleApp
Open the project file. It should contain a configuration similar to this:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net11.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
Now replace the contents of Program.cs with a small processing example.
using System.Diagnostics;
var stopwatch = Stopwatch.StartNew();
Console.WriteLine("Transaction processing started.");
var transactions = new[]
{
1200.50m,
2500.00m,
850.75m,
4200.00m,
1750.25m
};
decimal total = transactions.Sum();
stopwatch.Stop();
Console.WriteLine($"Transactions: {transactions.Length}");
Console.WriteLine($"Total Amount: {total}");
Console.WriteLine($"Execution Time: {stopwatch.ElapsedMilliseconds} ms");
This application performs a small calculation and prints the result.
Build and run it:
dotnet build -c Release
dotnet run -c Release
At this point, we have a working application. Next, we will change how it is published.
Step 2: Understand the Different Publishing Options
.NET supports multiple deployment models. Each one makes a different trade-off between deployment size, startup performance, and runtime requirements.
| Publishing option | Main purpose | Important consideration |
|---|
| Framework-dependent | Uses an installed .NET runtime | Smaller application-specific deployment |
| Self-contained | Includes the required runtime | Larger deployment output |
| Single-file | Bundles application-dependent files | Simplifies distribution |
| ReadyToRun | Precompiles assemblies | Can improve startup but increases size |
| Native AOT | Compiles application code into native machine code | Faster startup and potentially lower memory usage |
These options are not interchangeable.
For example, single-file publishing does not automatically make an application faster or smaller. It primarily simplifies distribution.
Native AOT is different because it compiles application code into native machine code during publishing and removes the need for a JIT compiler at runtime.
For a small console application, Native AOT is worth testing.
Step 3: Enable Native AOT
Native AOT is one of the most useful options when your application needs fast startup and does not depend on runtime code generation.
To enable it, update the project file:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net11.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<PublishAot>true</PublishAot>
</PropertyGroup>
</Project>
The important setting is:
<PublishAot>true</PublishAot>
This tells the .NET SDK to use Native AOT when publishing the application.
Native AOT also enables trimming because the compiler needs to determine which code is required and which code can be removed.
Unlike traditional JIT-based applications, Native AOT does not generate machine code dynamically when the application starts.
Publish the application for a specific platform:
dotnet publish -c Release -r win-x64
For Linux:
dotnet publish -c Release -r linux-x64
For macOS on Apple Silicon:
dotnet publish -c Release -r osx-arm64
Replace the runtime identifier according to your deployment environment.
One important point: Native AOT requires the appropriate native compiler toolchain. Depending on the operating system, you may need Visual C++ build tools, Clang, or other platform-specific development tools.
Also, publish separately for each operating system and architecture.
Step 4: Reduce Application Size Using Trimming
Trimming removes code that static analysis determines is not required by your application.
For example, a project may reference a library containing dozens of features while using only a small portion of them.
Without trimming, unnecessary code can remain in the published application.
You can enable trimming for a compatible application using:
<PropertyGroup>
<PublishTrimmed>true</PublishTrimmed>
<TrimMode>full</TrimMode>
</PropertyGroup>
The full trimming mode allows the linker to remove unused members from assemblies where trimming is supported.
However, trimming requires attention.
Some libraries use reflection to discover classes, methods, or properties dynamically. The linker may not recognize these dependencies through static analysis.
As a result, required code could be removed from the published application.
If your application uses reflection, serialization, dependency injection, or dynamically loaded components, test the trimmed output carefully.
Do not enable full trimming in production without validating the application.
Step 5: Optimize for Size or Execution Speed
Another useful publishing option is OptimizationPreference.
.NET provides this setting to communicate whether the application should prioritize size or execution speed during Native AOT compilation.
For example, to prioritize a smaller executable:
<PropertyGroup>
<PublishAot>true</PublishAot>
<OptimizationPreference>Size</OptimizationPreference>
</PropertyGroup>
To prioritize execution speed:
<PropertyGroup>
<PublishAot>true</PublishAot>
<OptimizationPreference>Speed</OptimizationPreference>
</PropertyGroup>
The difference is important.
When you choose Size, the compiler favors a smaller native executable. Some execution performance may be affected.
When you choose Speed, the compiler prioritizes execution performance, potentially producing a larger binary.
There is no universal winner. The right choice depends on the workload.
For a short-lived command-line utility, startup and deployment size may matter more than peak throughput.
For a continuously running data-processing application, execution speed may be more important.
Step 6: Compare the Published Output
Do not assume that Native AOT has reduced your application size simply because it is enabled.
Publish the application using different configurations and compare the output.
For example:
dotnet publish -c Release -r win-x64 \
--self-contained true
For Native AOT:
dotnet publish -c Release -r win-x64 \
-p:PublishAot=true
For a single-file deployment:
dotnet publish -c Release -r win-x64 \
-p:PublishSingleFile=true
Record the executable size and total deployment folder size.
You should also measure:
- Application startup time.
- Time taken to complete the actual workload.
- Peak memory consumption.
- CPU utilization.
- Build and publishing time.
Remember that an optimized executable is not automatically the fastest application in every scenario.
Native AOT generally improves startup behavior and can reduce memory usage, but a JIT-compiled application may achieve better steady-state throughput for certain workloads.
Step 7: Validate the Application Before Production
This is the part developers should not skip.
A successful Native AOT publish does not guarantee that every application feature will work correctly.
For example, consider an application that dynamically loads assemblies:
var assembly = Assembly.LoadFrom("Plugin.dll");
This type of dynamic loading is not supported in the normal Native AOT deployment model.
Similarly, runtime code generation through Reflection.Emit is not supported.
If your application depends on these capabilities, you may need to redesign that functionality or use a different publishing model.
For JSON serialization, source generation is a useful approach because it makes serialization metadata available at compile time.
For example:
using System.Text.Json;
using System.Text.Json.Serialization;
public class Transaction
{
public int Id { get; set; }
public decimal Amount { get; set; }
}
[JsonSerializable(typeof(Transaction))]
internal partial class AppJsonContext
: JsonSerializerContext
{
}
You can then serialize using the generated metadata:
var transaction = new Transaction
{
Id = 101,
Amount = 2500.50m
};
string json = JsonSerializer.Serialize(
transaction,
AppJsonContext.Default.Transaction);
Console.WriteLine(json);
This avoids depending on runtime reflection for the serialization metadata.
For production applications, test the actual published executable rather than relying only on tests executed through the development environment.
Common Mistakes to Avoid
| Mistake | Why it causes problems | Better approach |
|---|
| Enabling Native AOT without checking dependencies | Some packages are not AOT-compatible | Review package compatibility |
| Assuming single-file means smaller | Bundling can increase executable size | Compare actual output sizes |
| Ignoring trimming warnings | Required dynamically accessed members may be removed | Resolve warnings and test published output |
| Publishing for the wrong architecture | Native binaries are platform-specific | Use the correct runtime identifier |
| Measuring only build time | Does not represent application performance | Measure startup and workload execution |
| Disabling globalization without checking requirements | Date, number, and culture behavior may change | Keep globalization support when needed |
Advantages and Disadvantages
Advantages
- Faster application startup through Native AOT.
- Potentially lower memory consumption.
- No separate .NET runtime installation required for Native AOT deployment.
- Smaller deployment output in many suitable scenarios.
- Easier distribution of command-line utilities.
- Reduced dependency on runtime JIT compilation.
Disadvantages
- Longer publishing and compilation times.
- Platform-specific builds are required.
- Dynamic loading and runtime code generation are restricted.
- Some NuGet dependencies may not support Native AOT.
- Trimming requires additional testing.
- The resulting executable may be larger when optimization prioritizes execution speed.
Summary
.NET provides several ways to make console applications smaller and faster, but the best results come from choosing the right publishing strategy for the workload.
Native AOT is particularly useful for short-lived command-line tools, containerized utilities, and applications where startup time and memory consumption matter. Trimming can further reduce unnecessary code, while OptimizationPreference allows developers to prioritize size or execution speed.
However, these optimizations come with compatibility requirements. Applications that depend heavily on reflection, dynamic loading, or runtime code generation may require additional changes.
My recommendation is to start with a normal Release build, measure the application, enable Native AOT, and compare the published results. Keep the optimization only when the measurements demonstrate a real improvement.
The goal is not simply to produce a smaller executable. It is to make the application faster to start, easier to deploy, and more efficient to operate in production.
Join the conversation! Your thoughts help the community grow.