A slow build is one of those problems that developers usually notice only after it starts affecting the whole team. A project that takes a few seconds to build is easy to ignore. A solution that takes several minutes can interrupt development, slow down pull requests, and make CI pipelines more expensive.
When a .NET solution becomes large, the problem is not always the C# compiler. MSBuild may be spending time evaluating projects, restoring dependencies, running analyzers, copying files, generating assets, executing custom targets, or building projects that did not actually need to be rebuilt.
This is why improving build performance should start with finding where the time is going.
.NET 11 provides a newer build environment, but upgrading alone does not guarantee faster builds. Existing project structure and build configuration still have a major impact on how much work MSBuild performs.
How MSBuild Actually Builds a Solution
MSBuild is the build engine used by .NET projects to coordinate the work required to produce an application or library.
A simplified build looks like this:
MSBuild starts
|
v
Project evaluation
|
v
Dependency graph
|
v
Restore
|
v
Compilation
|
v
Analyzers
|
v
Generated files
|
v
Copy / publish / packaging
|
v
Output
Not every build spends the same amount of time in every stage.
For a small application, compilation may dominate the build.
For a large solution, project evaluation and dependency management can become significant.
A solution with dozens or hundreds of projects can also spend considerable time determining project relationships before compilation begins.
This is why looking only at compiler performance often leads to the wrong optimization.
Start by Measuring the Build
The first rule of build optimization is simple: measure before changing the project.
If a build takes 90 seconds, you need to know whether those 90 seconds are spent compiling C#, evaluating projects, running analyzers, restoring packages, or executing custom build targets.
Run a normal build first:
dotnet build
Then increase the amount of build information when investigating the problem:
dotnet build --verbosity diagnostic
Diagnostic verbosity produces a large amount of output, so it is not something you normally want in every build.
Use it when investigating a specific performance problem.
For larger solutions, binary logs are often more useful because they allow the build process to be inspected after the build completes.
dotnet build -bl
The resulting binary log can be opened with an MSBuild log viewer and used to identify expensive targets and project evaluation.
The goal is not to collect logs for their own sake.
The goal is to answer a practical question:
What work is taking most of the build time?
Project Evaluation Can Become Expensive
Before MSBuild compiles your C# code, it evaluates the project files.
Consider a project file containing multiple property groups, item groups, imports, conditions, package references, and custom targets.
MSBuild must process these instructions before it knows exactly what work needs to be performed.
In a large solution, this happens across many projects.
A project file can therefore become expensive even when the actual C# compilation is relatively small.
For example:
<PropertyGroup>
<EnableDefaultItems>true</EnableDefaultItems>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Example.Package" Version="1.0.0" />
</ItemGroup>
This is normal.
The problem appears when projects accumulate large amounts of conditional configuration and repeated MSBuild logic over time.
Common sources include:
- Large Directory.Build.props files
- Large Directory.Build.targets files
- Repeated ItemGroup definitions
- Complex conditions
- Custom targets
- Generated project files
- Excessive imports
- Unnecessary project references
If project evaluation is taking significant time, reducing unnecessary MSBuild work can have a larger effect than changing compiler settings.
Check Directory.Build.props and Directory.Build.targets
Central MSBuild files are useful because they allow common settings to be shared across many projects.
For example:
Solution
│
├── Directory.Build.props
├── Directory.Build.targets
│
├── App
│ └── App.csproj
│
├── Services
│ └── Services.csproj
│
└── Infrastructure
└── Infrastructure.csproj
The benefit is consistency.
The downside is that a change in one central file can affect every project below it.
Suppose a Directory.Build.targets file executes a custom task for every project:
<Target Name="GenerateMetadata" BeforeTargets="Build">
<Exec Command="some-tool generate" />
</Target>
If the solution contains many projects, that command may run many times.
The application may still build correctly, but the repeated work can become expensive.
Before adding custom targets to a central MSBuild file, ask whether every project really needs to execute them.
Avoid Building Projects That Do Not Need to Build
Project references create a dependency graph.
For example:
Web
|
+-- Application
|
+-- Domain
|
+-- Infrastructure
When Web is built, MSBuild needs to consider the referenced projects.
This is expected.
The problem occurs when the dependency graph contains unnecessary references.
For example, if Domain references Infrastructure only because one utility class was placed in the wrong project, a change in Infrastructure can cause more projects to rebuild.
Keep dependency direction intentional.
A common architecture is:
Web
|
Application
|
Domain
with Infrastructure implementing interfaces defined by the Application or Domain layers rather than creating unnecessary reverse dependencies.
A clean project graph can reduce both build work and architectural coupling.
ProjectReference Is More Expensive Than It Looks
A ProjectReference is not simply a namespace import.
It tells MSBuild that one project depends on another project as part of the build.
Consider:
<ItemGroup>
<ProjectReference Include="..\Shared\Shared.csproj" />
</ItemGroup>
When the referenced project changes, dependent projects may need to rebuild.
This is useful when the dependency is real.
However, adding project references for convenience can gradually create a large build graph.
If a project only needs a small set of reusable types, consider whether the dependency is architecturally correct.
The goal is not to remove project references blindly.
The goal is to avoid unnecessary build relationships.
Package Restore and Build Are Different Problems
Another common mistake is assuming every slow build is caused by compilation.
Consider:
dotnet restore
dotnet build --no-restore
If the second command is fast but a normal build is slow, restore may be part of the problem.
This distinction is useful in CI environments.
A pipeline might perform restore, build, test, and publish in separate stages.
If every stage repeats package work unnecessarily, the pipeline can spend considerable time doing work that could have been reused.
Caching NuGet packages in CI can help, but caching should be based on the actual package restore behavior rather than added blindly.
Analyzers Can Add Significant Build Time
Static analyzers are valuable because they detect problems before code reaches production.
However, analyzers also consume CPU and memory.
A project may compile quickly but spend much longer running analyzers.
This is particularly noticeable in large solutions with many source files.
If diagnostic logs show that analyzers consume a significant portion of build time, review which analyzers are enabled and where they are applied.
Do not disable all analyzers simply to make builds faster.
Instead, identify the expensive analyzers and determine whether they need to run for every project and every build.
For example, some checks may be appropriate during CI but unnecessary for every local development build.
The right solution depends on the team's quality requirements.
Incremental Builds Are Important
MSBuild is designed to avoid repeating work unnecessarily.
This is called incremental building.
If a developer changes one source file, the build system should not need to perform every possible operation from scratch.
A good incremental build looks roughly like this:
Change one file
|
v
Identify affected project
|
v
Compile required work
|
v
Run required targets
|
v
Produce updated output
A poorly designed custom target can break this efficiency.
For example, a target that always runs because its inputs and outputs are not declared correctly can execute during every build.
That creates unnecessary work.
If you maintain custom MSBuild targets, understand inputs and outputs and make the target incremental where possible.
Custom Exec Commands Need Special Attention
Custom shell commands are convenient.
For example:
<Target Name="GenerateClient" BeforeTargets="Build">
<Exec Command="node generate-client.js" />
</Target>
The problem is that MSBuild does not automatically know whether the generated output is already up to date.
If the command runs every time, it can slow down local development significantly.
A better target declares the files that it consumes and produces when appropriate:
<Target Name="GenerateClient"
BeforeTargets="Compile"
Inputs="client-schema.json"
Outputs="generated\Client.g.cs">
<Exec Command="node generate-client.js" />
</Target>
Now MSBuild has information it can use to determine whether the target needs to run.
The exact implementation depends on the tool and generated files, but the principle is important: build systems work better when dependencies are explicit.
Generated Code Can Affect Build Performance
Source generators and other code-generation mechanisms can reduce manual work, but they still participate in the build.
If a generator processes a large amount of input on every compilation, its cost can become noticeable.
When investigating slow builds, include generated-code work in your analysis.
Ask:
- What generates the code?
- When does generation happen?
- Does it process the whole solution?
- Can the generated output be reused?
- Is the generator running for projects that do not need it?
The answer can reveal build time that would otherwise be incorrectly attributed to the compiler.
CI Builds Need Separate Investigation
A build that is fast locally can still be slow in CI.
The environment is different.
CI may have:
- A clean workspace
- Cold package caches
- Fewer CPU resources
- Different SDK configuration
- Different build parameters
- Additional analyzers
- Test and coverage instrumentation
- Container overhead
Therefore, measure local and CI builds separately.
Do not optimize local builds and assume the CI pipeline will automatically improve.
A useful CI investigation separates the stages:
Restore
|
v
Build
|
v
Test
|
v
Coverage
|
v
Publish
This makes it easier to identify which stage is responsible for the slowdown.
A Practical Process for Finding Slow Build Work
When a .NET solution starts building slowly, use this process.
Step 1: Establish a Baseline
Record the normal build time.
Run the same command several times so that one unusual build does not distort the result.
Step 2: Capture a Binary Log
Use:
dotnet build -bl
Inspect the resulting build timeline.
Step 3: Find the Slowest Projects
Determine which projects consume the most build time.
Do not assume the largest project is automatically the slowest.
Step 4: Separate Evaluation From Compilation
Check whether the problem occurs before compilation begins or during compilation.
This distinction determines where you should investigate next.
Step 5: Inspect Custom Targets
Look for targets that run on every build.
Pay particular attention to Exec commands, code generation, file copying, and external tools.
Step 6: Review Project Dependencies
Look for unnecessary ProjectReference relationships and dependency chains.
Step 7: Test Incremental Builds
Change a single source file and observe how much work MSBuild performs.
If a small change triggers large amounts of unrelated work, investigate the dependency graph and custom targets.
Common Mistakes
Increasing Hardware Without Finding the Problem
More CPU can help compilation, but it does not fix inefficient MSBuild configuration.
First determine what the build is actually doing.
Disabling Analyzers Globally
This may reduce build time but also removes useful quality checks.
Identify expensive analyzers instead of disabling the entire analysis system.
Adding More Caching
Caching helps when the same work can be reused.
It does not fix a build that is repeatedly performing unnecessary work.
Using Custom Targets Without Incremental Inputs
A target that runs every time can become one of the biggest sources of build overhead.
Define inputs and outputs where appropriate.
Optimizing Only the Compiler
Compilation is only one part of a modern .NET build.
Project evaluation, restore, analyzers, generation, and custom tasks can all contribute to the total build time.
Build Performance Checklist
| Area | What to Check |
|---|
| Measurement | Capture actual build timings |
| Binary log | Identify expensive targets and projects |
| Project evaluation | Check central MSBuild files |
| References | Remove unnecessary ProjectReference dependencies |
| Restore | Separate restore time from compilation |
| Analyzers | Find expensive analysis |
| Custom targets | Check commands running on every build |
| Incremental builds | Verify unchanged work is skipped |
| Generated code | Check generation cost |
| CI | Measure pipeline stages independently |
Summary
Faster MSBuild builds start with finding the work that is actually slowing the build down.
Use build logs, inspect project dependencies, review custom targets, check analyzers, and verify that incremental builds are working correctly. The goal is not to remove useful build work, but to stop MSBuild from repeating work that does not need to happen.
Join the conversation! Your thoughts help the community grow.