A .NET build that takes a few seconds during development can become a major bottleneck as a solution grows. A project that once compiled quickly may eventually take several minutes, even when the code changes are small.
The first reaction is often to upgrade the machine, add more CPU resources, or enable parallel builds. These changes may help, but they do not address every cause of slow builds. Sometimes, the real problem is a single MSBuild target that performs unnecessary work, executes repeatedly, or waits for an external process.
MSBuild organizes the build process into targets and tasks. Targets define groups of operations, while tasks perform actions such as compiling source files, copying content, generating code, or running external commands. Understanding which target consumes the most time helps you identify where optimization will have the greatest effect.
This article explains how to investigate slow MSBuild targets, collect diagnostic information, compare build performance, and optimize expensive operations without breaking the build.
Understand How MSBuild Targets Affect Build Performance
MSBuild is the build engine used by .NET projects and solutions. It evaluates project files, imports build configuration, determines which targets need to run, and executes the tasks associated with those targets.
A simplified project might look like this:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
</PropertyGroup>
<Target Name="GenerateBuildMetadata"
BeforeTargets="Build">
<Exec Command="dotnet tool run generate-metadata" />
</Target>
</Project>
In this example, the GenerateBuildMetadata target runs before the Build target. If the command takes 15 seconds, every build that executes this target may incur that additional cost.
The target might be necessary, but it could also be doing unnecessary work. For example, it may regenerate metadata even when the inputs have not changed.
Common causes of slow MSBuild targets include:
- Repeated code generation.
- Excessive file copying.
- External commands that take a long time to execute.
- Custom targets that run during every build.
- Expensive dependency resolution.
- Large projects with unnecessary build dependencies.
- Tasks that execute sequentially when independent work could run in parallel.
- Targets that disable incremental build behavior.
- Repeated project evaluations caused by complex build configurations.
The goal is not to assume which target is responsible. It is to collect timing data and identify the expensive operation first.
Start with a Reproducible Build
Before investigating individual targets, establish a consistent way to reproduce the problem.
Run the same build command several times and observe whether the delay occurs consistently.
For example:
dotnet build MyApplication.sln --configuration Release
Record the total duration and note whether the build is clean or incremental.
A clean build starts without existing build outputs, while an incremental build may reuse outputs from previous builds. The two scenarios can have very different performance characteristics.
To compare them more reliably, use a controlled environment and avoid changing multiple variables between runs.
Separate Restore Time from Build Time
Package restore and compilation are different operations. If package restore is slow, investigating compilation targets will not necessarily solve the problem.
Run restore separately:
dotnet restore MyApplication.sln
Then build without restoring packages again:
dotnet build MyApplication.sln \
--configuration Release \
--no-restore
This separation helps you determine whether the delay occurs during dependency restoration or the actual build.
Keep in mind that other operations, such as project evaluation, source generation, resource processing, and custom targets, can still contribute to the build duration.
Check Whether the Problem Is Clean or Incremental
Run the build normally, then compare it with a build that starts without existing outputs.
For a controlled clean-build test, use:
dotnet clean MyApplication.sln --configuration Release
dotnet build MyApplication.sln --configuration Release
This is useful for reproducing a clean build, but it is not identical to every possible fresh-checkout environment. Some generated files or external caches may remain outside the normal project output directories.
Do not run clean-build experiments concurrently against the same working directory. One process could delete files that another process is using.
If the clean build is slow but incremental builds are fast, investigate compilation and code generation. If both are slow, examine targets that execute on every build, repeated file operations, and other persistent work.
Use MSBuild Diagnostic Logging
MSBuild diagnostic logging is one of the most useful ways to understand what happens during a build.
The diagnostic verbosity level records detailed information about project evaluation, target execution, task execution, and build dependencies.
Run the following command:
dotnet build MyApplication.sln \
--configuration Release \
--no-restore \
--verbosity diagnostic
The output can be very large, especially for solutions containing many projects. Save it to a file so you can search and compare it later.
For example, in PowerShell:
dotnet build MyApplication.sln `
--configuration Release `
--no-restore `
--verbosity diagnostic *> msbuild-diagnostic.log
You can then search the log for target execution and task timing information.
Diagnostic output can help answer questions such as:
- Which targets executed?
- Which tasks ran inside those targets?
- Did a target execute more than once?
- Which project introduced a custom target?
- Did an external command run during the build?
- Was a target skipped because its outputs were already up to date?
Use the Summary at the End of the Build
MSBuild diagnostic output often includes a summary of target and task execution times.
Look for sections identifying targets that consumed the most time and tasks with significant execution durations. The exact formatting can vary with the MSBuild version and build configuration.
For example, a hypothetical timing summary might show:
| Target or task | Execution time |
|---|
| GenerateBuildMetadata | 18 seconds |
| CoreCompile | 12 seconds |
| CopyFilesToOutputDirectory | 7 seconds |
| ResolveReferences | 5 seconds |
These figures are illustrative, not benchmark results.
If GenerateBuildMetadata takes longer than compilation, it is a strong candidate for investigation. However, confirm what the target does before changing it. Some expensive operations are necessary, and removing them could produce incorrect build outputs.
Also distinguish target execution time from total build time. Targets can depend on other targets, and the reported timing of an enclosing operation may not mean that all of the time was spent directly in its own task.
Create a Binary Log for Deeper Analysis
For large solutions, a binary MSBuild log is often easier to analyze than a lengthy diagnostic text file.
A binary log records structured information about the build process, including project evaluation, target execution, task execution, and timing details.
Create one with the following command:
dotnet build MyApplication.sln \
--configuration Release \
--no-restore \
-bl:build.binlog
The command creates a file named build.binlog in the current directory.
You can inspect the file using MSBuild Structured Log Viewer, a tool designed to explore binary MSBuild logs.
The viewer allows you to navigate the build tree, inspect individual projects and targets, examine task parameters, and investigate timing information.
For example, if a custom target invokes an external executable, you can locate that target and inspect the associated task to understand how the command was executed.
Find the Slowest Targets
After opening the binary log, inspect the target and task timing information.
Start with the targets that consume the most time, then expand them to examine their child tasks.
A useful investigation sequence is:
- Find the slowest targets in the build.
- Open a target and inspect its tasks.
- Identify any slow external commands or repeated operations.
- Check whether the target executes in multiple projects.
- Inspect its inputs, outputs, and dependencies.
- Determine whether the work is required on every build.
This process narrows the investigation from the entire solution to a specific operation.
Check for Repeated Target Execution
A target might be expensive because it runs unnecessarily often.
For example, a custom target may be imported into multiple projects, causing each project to execute the same external tool.
Another possibility is that the target participates in multiple build phases.
A binary log can help reveal these patterns. Examine the target's project context and execution sequence rather than relying only on its name.
If several projects execute the same code-generation command independently, consider whether the operation can be centralized or whether each project genuinely requires a separate output.
Do not combine targets simply because they have similar names. They may generate different files or depend on different project properties.
Identify Expensive Custom Targets
Custom targets are common sources of build delays because they often execute scripts or external tools that MSBuild cannot optimize automatically.
Consider this example:
<Target Name="GenerateApiClient"
BeforeTargets="Build">
<Exec Command="node tools/generate-api-client.js" />
</Target>
This target invokes a Node.js script before the build. If the script generates client code from an API schema, it may run even when neither the schema nor the generator has changed.
The unnecessary execution can increase build time for every developer and CI build.
Make Targets Incremental
MSBuild supports incremental target execution through its Inputs and Outputs attributes.
For example:
<Target Name="GenerateApiClient"
BeforeTargets="CoreCompile"
Inputs="api/schema.json;tools/generate-api-client.js"
Outputs="Generated/ApiClient.cs">
<Exec Command="node tools/generate-api-client.js" />
</Target>
This tells MSBuild that the target depends on the schema and generator script and produces the specified output.
When the output is up to date relative to the inputs, MSBuild can skip the target.
However, this configuration is correct only if the declared inputs and outputs accurately represent the operation.
If the generator also depends on configuration files, templates, environment settings, or additional source files, those dependencies must be accounted for. Otherwise, MSBuild might skip generation when it should run again.
Similarly, if the generator produces several files, make sure the target's output tracking accurately represents the complete output set. A missing generated file must not go unnoticed simply because one declared output exists.
Incremental execution should preserve correctness before it improves speed.
Find Slow File Copy and Code Generation Operations
Not every slow target involves compilation. File copying and generated code can also become significant as a solution grows.
Investigate Repeated File Copying
A project might copy large assets into its output directory during every build.
For example, a custom target could contain:
<Target Name="CopyApplicationAssets"
AfterTargets="Build">
<ItemGroup>
<ApplicationAsset Include="Assets/**/*" />
</ItemGroup>
<Copy SourceFiles="@(ApplicationAsset)"
DestinationFolder="$(OutputPath)Assets/%(RecursiveDir)" />
</Target>
This target copies every file under the Assets directory. For a project with many large files, the operation may consume considerable time.
Before optimizing it, determine whether the default SDK build already handles those files. A custom copy target may duplicate work that is already performed by the standard build process.
Also check whether every file needs to be copied for every configuration. If the application requires all assets, skipping the copy could produce an incomplete deployment package.
Investigate Source Generators
Source generators can increase build time by analyzing code and producing additional source files.
If compilation is slow, determine whether the time is spent in the compiler, a source generator, or another task that runs before compilation.
Inspect the relevant compiler diagnostics and generator configuration. Look for generators that repeatedly analyze large inputs or generate unnecessarily large amounts of source code.
Avoid disabling a generator merely to improve build time. Generated files may be required for correct application behavior.
Instead, investigate whether the generator supports incremental processing, whether its inputs are unnecessarily broad, and whether the project is including files that do not need to participate in generation.
Understand Why Parallel Builds May Not Help
MSBuild can build independent projects in parallel, but not every target can benefit from parallel execution.
For example:
dotnet build MyApplication.sln \
--configuration Release \
--no-restore \
--maxcpucount
The maxcpucount option allows MSBuild to use multiple worker processes for eligible build work.
Parallel execution can improve performance when a solution contains independent projects. However, a target that invokes a slow external service or processes files sequentially may remain the main bottleneck.
Parallel builds can also increase memory usage and expose problems in custom targets that share output files.
Before increasing parallelism, identify whether the slow operation is CPU-bound, I/O-bound, or waiting for an external process.
If a target is needlessly repeated, fixing that duplication may be more effective than running more work simultaneously.
Common Mistakes When Investigating Slow Builds
Optimizing Before Measuring
Changing project settings without timing the build can waste time or introduce new problems.
Collect diagnostic information first, identify the expensive target, and measure the result after each change.
Assuming CoreCompile Is Always Responsible
The compiler is only one part of the build pipeline. Code generation, file copying, dependency resolution, and custom scripts can consume more time than compilation.
Inspect the target timings before deciding where to focus.
Making a Target Incremental Without Tracking All Inputs
Incorrect input and output declarations can cause stale generated files to be reused.
Include all dependencies that affect the output, and test the target after changing each important input.
Comparing Builds with Different Conditions
Comparing a clean build on one machine with an incremental build on another does not produce a reliable result.
Keep the source revision, configuration, SDK, dependency state, and build conditions as consistent as possible.
Ignoring Build Correctness
A faster build is not an improvement if it produces incomplete files or skips necessary generation steps.
After optimizing a target, run the relevant tests and verify that the resulting artifacts contain everything required for deployment.
Best Practices for Maintaining Fast MSBuild Builds
Use the following practices to prevent build performance problems from returning:
- Keep a repeatable build command for performance investigations.
- Separate package restore from compilation when diagnosing delays.
- Use diagnostic logs to identify expensive targets and tasks.
- Generate binary logs when a large solution requires deeper analysis.
- Track the inputs and outputs of custom generation targets accurately.
- Avoid copying files that the standard SDK build already handles.
- Review custom targets that invoke external tools.
- Use explicit SDK versions when consistent build behavior is important.
- Test changes under both clean and incremental build conditions.
- Measure performance after each optimization and verify the resulting artifacts.
For teams with large solutions, consider keeping a small set of representative build commands and periodically comparing their durations. A sudden increase can indicate a new dependency, an expensive target, or a change in build configuration.
Build performance monitoring does not need to be complicated. Even recording total build time and the slowest targets can make regressions easier to identify.
Troubleshooting Checklist
Use this checklist when a .NET build takes longer than expected.
| Problem | What to inspect | Possible action |
|---|
| Restore is slow | Package sources and dependency resolution | Investigate restore separately |
| Compilation is slow | Compiler activity and source generators | Reduce unnecessary compilation work |
| A custom target is slow | Target and task timings | Optimize or remove unnecessary operations |
| Code generation runs every time | Target inputs and outputs | Add accurate incremental tracking |
| File copying dominates | Copy tasks and asset sizes | Remove duplicate copying or unnecessary assets |
| Build performance varies | Caches, parallel processes, and external services | Repeat tests under controlled conditions |
| CI builds are slower than local builds | Runner resources and environment differences | Compare tool versions and execution conditions |
| Build becomes faster but tests fail | Skipped work or incomplete generated files | Restore required operations and validate outputs |
The most useful diagnostic result is a clear connection between a slow operation and the time it adds to the build. Once you know which target is responsible, you can make a targeted change instead of modifying the entire build configuration.
Advantages and Disadvantages of MSBuild Diagnostic Tools
| Advantages | Disadvantages |
|---|
| Identifies expensive targets and tasks | Diagnostic logs can be large |
| Helps detect repeated work | Requires time to interpret unfamiliar logs |
| Supports evidence-based optimization | Logging can add overhead and produce sensitive details |
| Helps compare clean and incremental builds | Results can vary with caches and environment conditions |
| Provides insight into custom build logic | Does not automatically determine the safest fix |
For quick investigations, diagnostic verbosity may provide enough information. For complicated solutions, binary logs offer a more structured view of the build.
Treat build logs as potentially sensitive. They may contain file paths, command-line arguments, project properties, or other information that should not be shared publicly without review.
Summary
Slow MSBuild builds are not always caused by the compiler. A custom target, repeated code generation, file copying, dependency resolution, or an external command can consume a significant portion of the total build time.
Start by reproducing the problem under consistent conditions. Separate package restore from compilation, collect diagnostic logs, and inspect target and task execution times. Use a binary log when you need a deeper view of the build process.
Once you identify the expensive target, investigate why it runs and whether its work can be reduced or skipped safely. Incremental target execution, explicit dependency management, and removal of duplicate operations can improve build performance without compromising correctness.
The objective is to optimize the operation that actually causes the delay. Measuring first and validating every change helps keep .NET builds fast, predictable, and reliable as the solution grows.
Join the conversation! Your thoughts help the community grow.