A slow build can waste a surprising amount of development time. You make a small change to a C# file, run the build, and wait while the project restores packages, compiles source files, generates code, copies resources, and runs additional build steps. When the same delay happens several times a day, it can affect both individual productivity and the speed of a team's development process.
The difficult part is that compilation is not always the reason a build takes too long. An MSBuild project can execute targets that perform code generation, run external tools, validate files, copy assets, or invoke custom scripts. Some of these operations run before compilation, while others execute after the compiler has finished.
If you investigate only the C# compiler, you may miss the task that is actually slowing down the build.
MSBuild provides diagnostic logging and binary logs that help developers identify which targets and tasks consume the most time. Once you know where the delay occurs, you can investigate the corresponding project configuration and decide whether the work can be removed, optimized, or skipped safely.
This article explains how MSBuild targets and tasks work, how to capture detailed build timings, how to inspect a binary log, and how to identify common causes of slow builds in .NET projects.
Understand How MSBuild Builds a Project
MSBuild is the build engine used by many .NET projects. It reads project files, evaluates properties and items, determines which targets should run, and executes the tasks associated with those targets.
A target groups a set of build operations. A task performs a specific operation, such as compiling source code, copying files, or running an external command.
For example, a simplified project might contain the following target:
<Target Name="GenerateApplicationFiles"
BeforeTargets="CoreCompile">
<Exec Command="dotnet tool run code-generator" />
</Target>
This target runs before the CoreCompile target. Its task invokes an external command that generates application files.
If the command takes 20 seconds, the compiler may appear to start late even though the compiler itself is not slow. The delay occurs because MSBuild must complete the earlier target before compilation can proceed.
Real .NET projects usually contain many targets imported from the .NET SDK, NuGet packages, and custom build files. A target can also depend on other targets, so the order of execution is not always obvious from the project file alone.
What is an MSBuild target?
A target is a named unit of build work. Common examples in .NET projects include targets related to compilation, dependency resolution, resource generation, publishing, and copying output files.
Targets can run because they are explicitly requested, because another target depends on them, or because they are connected through properties such as BeforeTargets and AfterTargets.
A target can also be skipped when its conditions are not satisfied or when MSBuild determines that its work does not need to run.
What is an MSBuild task?
A task performs the actual operation inside a target. Examples include built-in tasks such as Copy and Exec, as well as tasks supplied by SDKs or third-party packages.
When a target is slow, the next step is to inspect its tasks. A target may contain one expensive task, several smaller tasks, or a task that starts an external process and waits for it to finish.
This distinction is important because the best optimization depends on where the time is spent. Removing an unnecessary target may eliminate the delay entirely, while optimizing a single task may be enough if the target is otherwise required.
Start by Measuring the Build
Before changing the project file, establish a baseline. Run the same build command that normally exhibits the delay and record how long it takes.
For a typical .NET project, start with:
dotnet build MyApplication.csproj
Use the actual project path for your application. If the slow build occurs in a solution, use the same solution-level command that reproduces the problem.
Avoid changing several build properties at once. For example, changing the target framework, configuration, and parallel-build settings simultaneously makes it difficult to determine which change affected performance.
Also distinguish between different build scenarios:
- A clean build may compile and generate everything from scratch.
- An incremental build may reuse outputs from a previous build.
- A restore may download packages or evaluate dependency information.
- A publish operation may perform additional steps that do not occur during a normal build.
If developers experience a delay after every small code change, an incremental build is especially important to investigate. A target that repeatedly performs unnecessary work can make even a small change expensive.
Capture MSBuild Timing Information
MSBuild provides a performance summary that reports how much time was spent in targets and tasks.
Run the build with the detailed summary enabled:
dotnet build MyApplication.csproj -v:normal -clp:PerformanceSummary
The PerformanceSummary option asks MSBuild to report timing information at the end of the build. The exact amount of detail depends on the verbosity level and the build environment.
Look for targets or tasks with unusually high elapsed time. A target taking several seconds may deserve investigation, particularly if it runs on every incremental build.
Do not automatically assume that the target with the highest total time is the only problem. A target may be slow because it waits for another target, or it may run many times across a large solution. Compare its execution time with the actual build behavior you are trying to improve.
Understand the timing summary
Suppose a build summary shows the following illustrative timings:
| Target or task | Elapsed time |
|---|
| Custom code generation | 18 seconds |
| Resolve project references | 7 seconds |
| C# compilation | 5 seconds |
| Copy output files | 3 seconds |
These values are examples, not benchmark results.
In this case, investigating the C# compiler first would probably be the wrong decision. The custom code-generation step accounts for the largest delay.
Check whether the generator needs to run on every build, whether its inputs have changed, and whether it is performing unnecessary work. If its outputs are generated correctly and can be reused, incremental execution may provide a larger improvement than trying to optimize the compiler.
Generate a Binary Log for Detailed Investigation
A timing summary is useful, but it does not always explain why a target ran or which tasks contributed to its duration.
For a more detailed investigation, generate an MSBuild binary log.
Run:
dotnet build MyApplication.csproj -bl:build.binlog
MSBuild writes the build events to build.binlog. The log can contain target and task execution details, property values, project evaluations, and diagnostic messages.
Open the file using MSBuild Structured Log Viewer, a tool designed to explore MSBuild binary logs. It lets you navigate the build hierarchy, search for target names, inspect tasks, and examine the recorded execution events.
Binary logs can contain sensitive information, including file paths, command-line arguments, environment details, and potentially values associated with build properties. Review the contents before sharing the log outside your organization.
What should you look for in the binary log?
Start with the project that exhibits the slow build. Expand its target list and locate the targets that ran around the time of the delay.
Then inspect the tasks within those targets. Look for operations such as:
- External commands that execute code generators or scripts.
- Copy tasks that process large numbers of files.
- Package-resolution work that repeats unnecessarily.
- Custom validation steps that run on every build.
- Targets that execute even when their inputs have not changed.
If a target invokes an external process, inspect the command and determine whether the delay occurs inside the process or before it starts.
For example, a code generator might spend most of its time reading schema files or scanning a directory. In that case, changing the MSBuild target structure alone may not solve the underlying performance problem. You may need to optimize the generator itself or reduce the number of inputs it processes.
Find Targets That Run More Often Than Necessary
One common cause of slow builds is unnecessary repeated work.
A custom target might run every time MSBuild starts, even when its generated output is already up to date. This can happen when the target does not define appropriate inputs and outputs or when its conditions are too broad.
Consider this example:
<Target Name="GenerateSchema">
<Exec Command="dotnet tool run schema-generator" />
</Target>
This target runs the generator whenever MSBuild executes the target. If the generator processes a large schema, repeated execution can add substantial time to the build.
When the operation supports incremental execution, declare its inputs and outputs:
<Target Name="GenerateSchema"
Inputs="Schemas\model.json"
Outputs="Generated\Model.g.cs">
<Exec Command="dotnet tool run schema-generator" />
</Target>
MSBuild can use the input and output timestamps to determine whether the target needs to run. If the output is up to date relative to the input, MSBuild may skip the target.
This example illustrates the incremental-build pattern, but it is not sufficient for every generator. A real generator may depend on multiple schema files, configuration files, referenced assemblies, or tool versions. Those dependencies must be represented correctly for the skip decision to remain valid.
If a required input changes but is not declared, MSBuild may incorrectly reuse an old output. If an output is missing or incorrectly declared, the target may run every time.
Incremental build logic must therefore reflect the generator's actual dependency graph.
Investigate Slow External Commands
MSBuild targets frequently invoke tools that are not part of the build engine itself. These tools might include code generators, JavaScript build systems, native compilers, formatters, security scanners, or custom PowerShell scripts.
When an Exec task is slow, determine whether the delay comes from process startup, the external tool, file-system access, or another dependency.
The following target illustrates how a project might invoke an external tool:
<Target Name="GenerateClient"
BeforeTargets="CoreCompile">
<Exec Command="npm run generate-client" />
</Target>
If this target runs during every C# build, the project may be spending time executing a separate JavaScript toolchain even when no client-generation inputs have changed.
Investigate whether the operation belongs in every build. If the generated files are part of the project, consider using appropriate input and output declarations or a separate explicit generation step.
Do not simply remove the target because it is slow. If compilation depends on the generated files, removing the operation can cause missing-file errors or stale code. The correct solution is to preserve the required dependency while avoiding unnecessary execution.
Check Whether Restore Is the Actual Bottleneck
Sometimes developers describe the entire build workflow as slow when the delay is caused by package restore rather than compilation.
NuGet restore resolves package dependencies and prepares the assets needed for the build. It may take longer when packages are not cached, network access is slow, or the dependency graph has changed.
To investigate the build separately from restore, run:
dotnet restore MyApplication.csproj
dotnet build MyApplication.csproj --no-restore
The second command skips the restore step. Use it only after a successful restore has prepared the required assets.
Compare the timings of the two operations. If restore accounts for most of the delay, inspect package sources, network access, dependency changes, and the package cache.
This distinction is especially useful in CI/CD pipelines. A fresh build agent may need to download many packages, while a developer's machine may already have them cached.
Do not disable restore in a workflow without ensuring that the dependencies are available and current. Skipping restore is a diagnostic and workflow optimization option, not a replacement for dependency management.
Understand Parallel Builds and Project Dependencies
MSBuild can build independent projects in parallel. However, a large solution may still spend time waiting for projects that depend on one another.
For example, an application project may depend on several class libraries. Those libraries must be built before the application can complete its own compilation.
Use a binary log to understand which projects are building, which targets are waiting, and whether a slow dependency is delaying the overall solution.
You can compare a normal build with a build that limits parallelism:
dotnet build MySolution.sln -m:1
The -m:1 option limits MSBuild to one worker node. It is useful as a diagnostic comparison, but it will often make a large build slower because independent projects can no longer build simultaneously.
If the build becomes unexpectedly faster under a different parallelism setting, investigate resource contention, shared output paths, external tools that cannot run concurrently, or tasks that compete for disk and CPU resources.
Do not assume that increasing parallelism always improves performance. The right setting depends on the dependency graph, machine capacity, and behavior of custom build tasks.
Common Mistakes When Investigating Build Performance
Optimizing the compiler before measuring the full build
Compilation is only one part of the process. Always inspect the complete build timing before focusing on compiler settings.
Removing a slow target without understanding its purpose
A target may generate files required by later steps. Removing it can create build failures or cause stale outputs to be used. Identify its inputs, outputs, and dependencies first.
Treating one timing result as conclusive
Build performance changes with caching, disk activity, machine load, package restore, and parallel execution. Repeat measurements under comparable conditions before deciding that an optimization works.
Adding incomplete input and output declarations
Incremental builds are only correct when MSBuild knows which files affect the output. Missing dependencies can cause outdated generated files to be reused.
Sharing binary logs without checking their contents
Build logs can expose internal paths, arguments, and configuration values. Inspect the log and remove sensitive information before sharing it with an external party.
Best Practices for Faster MSBuild Builds
Use timing summaries to find expensive targets, then capture a binary log to understand their execution. Check whether slow targets run unnecessarily and whether their incremental-build inputs and outputs are defined correctly.
Separate restore, build, test, and publish timings when diagnosing CI/CD performance. Investigate external commands independently when an Exec task accounts for a large portion of the build.
Keep custom targets as narrow as possible. Avoid scanning large directories or regenerating unchanged files when the output can be reused safely. Ensure that generated files have explicit dependencies and that parallel execution does not create conflicting writes.
Finally, measure the result after every meaningful change. A build optimization is successful only if it reduces the relevant build time without making the build unreliable or allowing stale artifacts to pass unnoticed.
Advantages and Disadvantages of MSBuild Performance Logging
Advantages
- Target and task timings help identify where build time is being spent.
- Binary logs provide detailed execution information that is difficult to obtain from a short console summary.
- Incremental-build analysis can reveal work that should not run after every source change.
- Separate restore and build measurements make CI/CD bottlenecks easier to isolate.
- Repeatable measurements allow teams to evaluate whether an optimization actually improves performance.
Disadvantages
- Detailed logs can be large and require time to inspect.
- Timing results may vary with machine load, caching, and parallelism.
- Binary logs may contain sensitive build information.
- Incremental-build optimization requires a correct understanding of task inputs and outputs.
- Changes to custom targets can introduce subtle correctness problems if dependencies are not declared accurately.
Summary
Finding a slow MSBuild target starts with measuring the complete build rather than assuming that the C# compiler is responsible. Use the performance summary to identify expensive targets and tasks, then capture a binary log to inspect what actually ran.
Once the delay is located, investigate whether the work is necessary, whether it runs more often than required, and whether an external tool or dependency is responsible. Restore, compilation, code generation, file copying, and project dependencies should be measured separately when possible.
The goal is not simply to make the build finish faster. It is to remove unnecessary work while preserving correct dependencies, fresh generated files, and reliable build results. With timing summaries, binary logs, and carefully designed incremental targets, developers can identify the real bottleneck and make targeted improvements instead of guessing.
Join the conversation! Your thoughts help the community grow.