A slow build is easy to blame on the compiler, the machine, or the size of the solution. In a small project, that might be close to the truth. In a larger .NET solution, however, the time spent building is usually spread across several different activities.
MSBuild has to evaluate project files, resolve project references, restore packages when required, run targets and tasks, invoke compilers, copy files, generate assets, and sometimes execute custom build steps. A solution can therefore become noticeably slower even when the amount of application code has not changed very much.
.NET 11 also includes MSBuild and SDK improvements that can reduce some build overhead. For example, the .NET SDK improves wildcard matching during project evaluation, which can help projects containing large numbers of directories and files. Microsoft reports faster project evaluation for the affected workloads.
But upgrading the SDK alone will not fix every slow build.
The useful approach is to first find where the time is being spent and then change the part of the build that is actually responsible for the delay.
How MSBuild Spends Time During a Build
Before optimizing a build, it helps to understand what dotnet build is doing.
A simplified build looks like this:
dotnet build
|
+-- Restore
|
+-- Project evaluation
|
+-- Resolve project dependencies
|
+-- Generate intermediate files
|
+-- Compile projects
|
+-- Run MSBuild targets
|
+-- Copy / generate outputs
|
+-- Produce final build output
dotnet build uses MSBuild underneath and supports both incremental and parallel builds.
That distinction matters because two builds that appear to do the same thing can have very different timings.
For example, an incremental build may reuse existing outputs:
Source file unchanged
|
v
Existing output still valid
|
v
Skip compilation
A clean build cannot do that:
No usable outputs
|
v
Evaluate projects
|
v
Compile dependencies
|
v
Compile application
When someone says “the build is slow,” the first question should therefore be:
“Is the slow build a clean build, an incremental build, a restore-heavy build, or a build with unnecessary work?”
That answer usually determines where to investigate next.
Start by Measuring the Build
Do not start by changing MSBuild properties randomly.
Run the build normally first:
dotnet build
Measure how long it takes.
Then compare it with an incremental build:
dotnet build --no-restore
If the second command is substantially faster, restore or dependency resolution may be contributing to the original build time.
This does not mean that restore is necessarily the problem. It simply gives you another measurement that helps separate the stages.
For CI environments, you should also compare cold and warm builds.
A cold build starts with little or no reusable state:
Fresh agent
|
v
Restore
|
v
Build
A warm build may have package caches and other reusable information available:
Existing environment
|
v
Reuse available state
|
v
Build
If local builds are fast but CI builds are consistently slow, investigate the CI environment before changing the project itself.
Use a Binary Log Instead of Guessing
One of the most useful tools for diagnosing MSBuild performance is the binary log.
Run:
dotnet build -bl:build.binlog
The -bl option enables MSBuild's binary logger and records detailed information about the build. The resulting .binlog can be opened with a structured log viewer to inspect targets, tasks, project evaluation, properties, and other build activity.
This is much more useful than staring at the normal console output.
A normal build might simply show:
Project A succeeded
Project B succeeded
Project C succeeded
Build succeeded.
That tells you whether the build worked, but not why it took two minutes.
A binary log gives you information that can help answer questions such as:
Which project took the longest?
Which target took the longest?
Which task consumed the most time?
Which projects were evaluated repeatedly?
Which dependencies caused additional builds?
For a large solution, this can quickly turn a vague performance problem into a specific investigation.
Keep in mind that detailed logging has some overhead. Microsoft notes that binary logging can have a measurable effect on large builds, so it should generally be used for diagnosis rather than permanently enabled for every build.
Find the Slow Project First
Suppose you have a solution containing:
MyCompany.sln
|
+-- Web
+-- Application
+-- Domain
+-- Infrastructure
+-- Reporting
+-- Shared
+-- Tests
If the entire solution takes several minutes, rebuilding every project repeatedly does not help you identify the problem.
Build individual projects and compare the timings.
For example:
dotnet build src/Domain/Domain.csproj
dotnet build src/Application/Application.csproj
dotnet build src/Infrastructure/Infrastructure.csproj
dotnet build src/Web/Web.csproj
This gives you a rough dependency map.
If Domain takes two seconds, Application takes five seconds, and Infrastructure takes sixty seconds, you already know where to look.
The next step is to inspect what Infrastructure is doing during its build.
This is usually more productive than trying global performance settings first.
Check Project Evaluation
MSBuild does more than compile C# code.
It first evaluates project files and imported MSBuild files to determine properties, items, targets, and dependencies.
A project with a very complicated .csproj can therefore spend meaningful time before the compiler even starts.
For example, this kind of configuration can become expensive in a large repository:
<ItemGroup>
<Compile Include="**/*.cs" />
<Content Include="**/*" />
<None Include="**/*" />
</ItemGroup>
Wildcard patterns are convenient, but broad patterns across large directory trees can cause more file-system work than expected.
The .NET SDK includes improvements to wildcard matching in .NET 11. The SDK's newer matcher is intended to improve project evaluation performance for projects with many files and directories, particularly when wildcard include and exclude patterns overlap.
Even with those improvements, project structure still matters.
Avoid unnecessary broad file patterns when a narrower pattern will do the job.
For example, instead of searching an entire repository for content that belongs only to one project, keep the pattern scoped to the project directory.
Look for Unnecessary Project References
Project dependencies can have a large effect on build time.
Imagine this structure:
Web
|
+-- Application
|
+-- Domain
|
+-- Infrastructure
If Application only needs types from Domain, it should not reference unrelated projects simply because they are available in the solution.
Unnecessary references make the dependency graph larger.
That can lead to more projects being evaluated or built when an apparently small change is made.
A useful question for every ProjectReference is:
“Does this project actually need to reference that project?”
For example:
<ItemGroup>
<ProjectReference Include="..\Domain\Domain.csproj" />
</ItemGroup>
is reasonable if the application layer directly depends on domain types.
But adding several unrelated project references “for convenience” makes the build graph harder to reason about.
Build performance and architecture are connected here.
A clean dependency graph is usually easier to build, test, and maintain.
Use Parallel Builds Carefully
MSBuild can build multiple projects in parallel.
This is one of the most useful performance features for multi-project solutions.
For example:
dotnet build -m
or:
dotnet build -m:8
The -m or -maxcpucount option controls how many MSBuild worker processes can run in parallel. MSBuild also supports parallel project execution through the BuildInParallel mechanism.
More parallelism, however, does not always mean a faster build.
Suppose your solution has many projects that all compete for:
CPU
Memory
Disk I/O
NuGet cache
File system access
Running too many workers can cause contention.
For example, a machine with limited memory might spend more time paging or competing for disk access when too many projects are built simultaneously.
Therefore, compare:
dotnet build -m:2
dotnet build -m:4
dotnet build -m:8
rather than assuming the largest number will be fastest.
The optimal value depends on the machine and the workload.
Watch for Build Race Conditions
Parallel builds can also expose problems that do not occur during serial builds.
A custom MSBuild task might write to a fixed temporary file:
temp/generated.json
If two projects execute that task at the same time, both processes may try to modify the same file.
The result can be:
File locked
Incomplete output
Random build failure
Intermittent exception
Corrupted generated file
MSBuild's parallel execution model can expose these race conditions because multiple worker nodes may execute work simultaneously.
If a build succeeds when you use:
dotnet build -m:1
but occasionally fails with parallelism enabled, do not treat -m:1 as the permanent fix.
It may simply be hiding a race condition.
Investigate custom build tasks, generated files, shared directories, and tools that are not designed for concurrent execution.
Check Custom MSBuild Targets
Custom targets are another common source of slow builds.
A project may contain something like:
<Target Name="GenerateClient" BeforeTargets="Build">
<Exec Command="some-tool generate-client" />
</Target>
This means the external tool runs as part of the build.
If that command takes 20 seconds and runs every time, developers pay that cost repeatedly.
The first question should be:
“Does this task actually need to run for every build?”
Sometimes the generated output can be made incremental.
For example:
<Target Name="GenerateClient"
BeforeTargets="Compile"
Inputs="api.json"
Outputs="Generated\Client.cs">
<Exec Command="some-tool generate-client api.json" />
</Target>
MSBuild can use Inputs and Outputs to determine whether a target needs to run again.
The exact configuration depends on the task, but the principle is important:
If an expensive task produces an output that is still valid, do not make MSBuild regenerate it unnecessarily.
Check Code Generation
Code generation can quietly become one of the biggest build costs in a large project.
Common examples include:
OpenAPI client generation
Entity generation
Source generators
Resource generation
Localization generation
Serialization metadata
Database model generation
A build might appear to be “compiling C#” while a large amount of time is actually being spent generating files.
This is another reason binary logs are useful.
Instead of guessing which compiler option is slow, inspect the targets and tasks that execute before compilation.
If code generation takes 40 seconds and compilation takes 10 seconds, optimizing compiler settings will not solve the real problem.
Avoid Rebuilding Unchanged Work
Incremental builds work best when build inputs and outputs are clearly defined.
A target that does not declare its inputs and outputs correctly may execute every time.
For example:
<Target Name="GenerateSomething"
BeforeTargets="Build">
<Exec Command="generate-tool" />
</Target>
MSBuild has little information here about when the generated output is still valid.
If the task supports incremental execution, define its inputs and outputs.
Conceptually:
Input changed?
|
Yes ------> Run task
|
No
|
v
Skip task
That small change can have a much larger effect than increasing the number of CPU cores used by the build.
Do Not Confuse Restore Time With Build Time
A common performance complaint is:
“dotnet build is taking five minutes.”
But the five minutes may include:
Restore
Project evaluation
Compilation
Code generation
Tests
Packaging
These are different workloads.
Use:
dotnet restore
separately when investigating restore performance.
Then:
dotnet build --no-restore
to measure the build itself.
This gives you a much clearer comparison.
If restore takes 90 seconds and compilation takes 20 seconds, the optimization target is obvious.
Check Package and Project Graph Size
Large solutions often accumulate dependencies over time.
A project may reference a package because it was needed years ago, even though the application no longer uses it directly.
That can increase restore work and dependency resolution complexity.
Review:
<PackageReference Include="Some.Package" Version="..." />
regularly.
Remove packages that are no longer needed.
Also look for packages that pull in large dependency trees.
The goal is not to remove packages simply because they exist.
The goal is to avoid doing build and restore work that provides no value to the application.
Use --no-restore in the Right Places
Once dependencies are already restored, CI or local scripts may be able to separate restore from build:
dotnet restore
dotnet build --no-restore
This is especially useful when a pipeline has already completed restore as a separate stage.
It prevents the build stage from performing the same restore operation again.
For example:
CI Pipeline
|
+-- Restore
|
+-- Build --no-restore
|
+-- Test --no-build
|
+-- Publish --no-build
The exact pipeline depends on the project, but avoiding unnecessary repeated work is the important idea.
Check Diagnostic Logging Only When Needed
When debugging a slow build, developers often use:
dotnet build -v:diag
This provides a large amount of information.
It can be useful, but it also increases logging overhead.
Microsoft specifically notes that detailed and diagnostic verbosity can make builds slower.
A better approach is:
Normal build
|
v
Binary log
|
v
Find suspicious target
|
v
Detailed/diagnostic logging if needed
Do not leave diagnostic logging enabled as the normal development configuration.
A Practical Investigation Workflow
When a .NET solution starts taking too long to build, use a repeatable process.
Step 1: Measure
Run:
dotnet build
Record the duration.
Step 2: Separate Restore
Run:
dotnet build --no-restore
Compare the result.
Step 3: Generate a Binary Log
Run:
dotnet build -bl:build.binlog
Open the log and find the slow projects, targets, and tasks.
Step 4: Build Individual Projects
Identify whether one project is responsible for most of the delay.
Step 5: Check Custom Targets
Look for:
Exec
Code generation
File copying
Packaging
External tools
Custom analyzers
Step 6: Check the Dependency Graph
Look for unnecessary ProjectReference entries and packages.
Step 7: Check Parallelism
Compare different worker counts on the same machine.
Step 8: Check Incremental Behavior
Find tasks that run every time even though their inputs have not changed.
Step 9: Test the Fix
Change one thing at a time and measure again.
This last step is important.
If you change ten MSBuild settings at once and the build becomes faster, you will not know which change actually helped.
Common Mistakes When Optimizing MSBuild
Increasing Parallelism Without Measuring
More workers can help, but they can also increase CPU, memory, and disk contention.
Disabling Everything in the Build
Removing analyzers, generators, and validation steps can make a build faster, but it may also remove important development safeguards.
Optimize the expensive work rather than blindly deleting it.
Using -m:1 as a Permanent Fix
Serial builds can hide race conditions and make builds significantly slower.
If parallel execution causes failures, investigate the underlying task or file-sharing problem.
Using Diagnostic Logging for Every Build
Diagnostic logging is for investigation, not normal development.
It can add overhead and make build output difficult to work with.
Optimizing the Compiler Before the Build Graph
If an external tool takes most of the build time, compiler settings will not solve the problem.
Find the largest cost first.
What Usually Gives the Biggest Improvement?
For a large .NET solution, the most useful improvements often come from reducing unnecessary work rather than simply adding hardware.
Focus on these areas:
Area | What to Check |
|---|
Project evaluation | Large wildcard patterns and complex imports |
Dependencies | Unnecessary project references |
Custom targets | Expensive commands running on every build |
Code generation | Generated output that is recreated unnecessarily |
Incremental builds | Missing or incorrect inputs and outputs |
Parallelism | Worker count and resource contention |
Restore | Repeated restore operations |
Logging | Excessive verbosity |
CI | Cold caches and repeated build stages |
SDK | Available MSBuild and SDK performance improvements |
The biggest improvement usually comes from finding the one part of the build that is doing unnecessary work repeatedly.
Best Practices for Faster .NET Builds
Keep project dependencies simple and intentional.
Use incremental targets for expensive generated files.
Separate restore from build in CI when that matches your pipeline design.
Use binary logs when investigating performance instead of relying only on console output.
Measure parallel build settings instead of assuming the highest worker count is best.
Remove obsolete packages and project references periodically.
Keep custom MSBuild targets small and predictable.
When upgrading to .NET 11, take advantage of SDK improvements, but still profile your own solution because SDK improvements cannot fix inefficient project configuration or custom build logic. The .NET 11 SDK specifically improves wildcard matching during project evaluation, which can help large projects with extensive file patterns.
Summary
Slow MSBuild builds are usually caused by work happening around compilation, not only by the C# compiler.
Start with a binary log, find the slow project or target, and remove unnecessary repeated work. Then measure the result before changing more settings. This approach is more reliable than simply increasing CPU usage or adding random MSBuild options.
Join the conversation! Your thoughts help the community grow.