Diagnosing slow and non-incremental builds: capturing binary logs with -bl, reading them in the MSBuild Structured Log Viewer, finding slow targets, tasks and analysers, spotting broken incrementality, and fixes such as Inputs/Outputs, caching and build server settings.
First I get evidence instead of guessing. dotnet build -bl writes a binary log with every property, item, target, task and timing, and I open it in the MSBuild Structured Log Viewer. For the slow build I look at the timeline and the target and task summaries. That shows whether time goes on restore, compilation, analysers or a custom target. For the broken incremental build, I build twice and look at why targets ran the second time. If CoreCompile runs again, one of its inputs changed. Usually a custom target rewrites a generated file on every build, or something uses CopyToOutputDirectory="Always". Other culprits are globs over huge folders and slow analysers, which -p:ReportAnalyzer=true measures. I fix the cause with Inputs/Outputs, by writing files only when their content changes, and by narrowing globs. Then I add a CI check that times a no-change rebuild.
"My approach is to get evidence before guessing:
dotnet build -blwrites a binary log with every property, item, target, task and timing, and I open it in the MSBuild Structured Log Viewer, using the timeline and the target and task summaries for the slow build, with-clp:PerformanceSummaryas a quick text view and-v:diagonly when I need more. For incrementality I build twice and read why targets ran the second time: ifCoreCompilereruns, an input changed, usually a custom target withoutInputsandOutputs, a file rewritten every build,CopyToOutputDirectory="Always", a glob over a huge folder, or a slow analyser, which-p:ReportAnalyzer=truemeasures. The mistake I see is guessing or buying bigger agents, which leaves the real cause in place.For example, our [70-project solution at company] had slowed from [2] to [15] minutes, and even no-change builds recompiled everything. My task was to find the cause, since it was costing every developer [hours] a week. I compared binary logs of two consecutive builds and saw
CoreCompilererunning because a shared target inDirectory.Build.targetswrote a timestamp into generated code every time. I made it write stable content locally and only when it changed, moved one expensive analyser to a nightly job, and added a CI guard on no-change build time. As a result, full builds dropped to [about 3] minutes and no-change builds to [about 10] seconds.Build performance decays quietly, one target or package at a time, so I treat it like production performance: measure, fix the mechanism, then guard it with a metric in CI. And I handle binary logs as sensitive artefacts, because they capture environment variables and properties that can include secrets."
dotnet build -bl:full.binlog # clean or normal build
dotnet build -bl:incremental.binlog # immediately again, with no changes
dotnet build -clp:PerformanceSummary # quick text summary of target and task times
For analyser cost: dotnet build -bl -p:ReportAnalyzer=true adds per-analyser timing to the log.
Open the logs in the MSBuild Structured Log Viewer (Windows app, or the cross-platform version / web viewer):
CoreCompile, ResolvePackageAssets, custom targets) and per task (Csc, Copy, Exec).$property TreatWarningsAsErrors) or a target to see where it was set or why it ran.In the second (no-change) build, every target should be skipped or trivially fast. For each target that ran, the log says why, for example "Input file X is newer than output file Y". Usual causes:
| Cause | Symptom | Fix |
|---|---|---|
Custom target without Inputs/Outputs |
Runs every build | Declare them |
A target writes a generated .cs file every time |
CoreCompile reruns in every project |
Write only if content changed (WriteLinesToFile with WriteOnlyWhenDifferent="true") |
CopyToOutputDirectory="Always" |
Copies every build, downstream projects rebuild | PreserveNewest |
Timestamps or build numbers in AssemblyInfo |
Every compile has new input | Only set versions in CI |
| Globbing a huge folder (node_modules, test data) | Slow evaluation | DefaultItemExcludes or Compile Remove |
ReportAnalyzer shows which. Consider running heavy ones only in CI, or only on changed projects.A 70-project solution went from 2 to 15 minutes over a few months, and every build recompiled everything. The binary log showed a GenerateVersionInfo target in Directory.Build.targets that wrote VersionInfo.g.cs with the current timestamp in every project. CoreCompile then reran everywhere. A second hotspot was a security analyser taking 40% of compile time. Writing the file only when the version changed, and running that analyser in a nightly CI job instead of every local build, brought full builds back to 3 minutes and no-change builds to 10 seconds.
Directory.Build.targets for build-time impact.Probes depth with MSBuild diagnostics and performance thinking.
Green flags: starts with -bl and the Structured Log Viewer; explains how to find why a target ran; knows common incrementality breakers; measures analysers; mentions binlog sensitivity; adds a regression guard.
Red flags: guesses; only suggests bigger machines; has never opened a binary log.
A dotnet build passes through distinct phases, and each one fails in its own way:
dotnet build
|
+-- restore NuGet: resolve the graph, download, write project.assets.json
| slow when: no package cache, slow feeds, huge graphs
|
+-- evaluation read every project, import .props/.targets, expand globs
| slow when: globs over node_modules or test data, deep imports
|
+-- execution run targets per project, in parallel across nodes
|
+-- ResolveReferences / ResolvePackageAssets
+-- custom targets (code generation, Exec, npm, ...) <-- common culprits
+-- CoreCompile -> Csc task: compiler + analysers + source generators
+-- copy to output (bin/), dependants pick up the new assembly
The two symptoms in the question point to different causes. A slow full build is about how much work each phase does. A non-incremental build means work is repeated that should have been skipped, and that is almost always one changed input causing a chain reaction.
| Command | What it gives you |
|---|---|
dotnet build -bl |
msbuild.binlog in the current folder: every property, item, import, target, task, message and timing |
dotnet build -bl:incremental.binlog |
A named log, so you can compare two builds |
dotnet build -clp:PerformanceSummary |
Text summary of evaluation, target and task times at the end of the console output |
dotnet build -bl -p:ReportAnalyzer=true |
Adds time per analyser and per source generator to the Csc task in the log |
MSBUILDDEBUGENGINE=1 (environment variable) |
Makes Visual Studio and other hosts write binary logs, into an MSBuild_Logs folder, for builds you can't start from the command line |
A binary log is small, lossless and replayable: dotnet build msbuild.binlog -v:diag (or dotnet msbuild msbuild.binlog -v:diag) replays it as a diagnostic text log without building again. Capturing with -bl is cheaper than -v:diag on the console and loses nothing.
The MSBuild Structured Log Viewer (msbuildlog.com: a Windows app, a cross-platform Avalonia app and a web viewer) shows the log as a tree. The views that matter for performance:
| View | Answers |
|---|---|
| Timeline | Which nodes were busy, what ran in parallel and where the critical path is |
| Target and task summaries (on the root node) | Total time per target and per task across all projects |
| Search | $target CoreCompile, $task Csc, $project Orders.Api, a property name or a file path |
| Properties and items per project | The final values after evaluation, and where each was set |
| Evaluation node | Evaluation time per project, and the import tree |
For each target the log records whether it ran and why:
Skipping target "CoreCompile" because all output files are up-to-date with respect to the input files. This is what you want to see in a no-change build.Building target "CoreCompile" completely. Input file "obj/.../VersionInfo.g.cs" is newer than output file "obj/.../Orders.Domain.dll". This names the input that broke incrementality.Inputs and Outputs has no such message: it simply runs every time. <Target Name="Gen" Inputs="@(Schema)" Outputs="$(IntermediateOutputPath)Gen.g.cs">
all Outputs exist AND newest Input is older than oldest Output ?
| yes | no
v v
skip the target run it (completely, or partially
("all outputs up-to-date") for 1:1 input-to-output mappings)
The check is purely timestamps. That's why a target that rewrites a file with the same content still breaks the chain: the content is identical but the timestamp is new, so CoreCompile sees a newer input. WriteLinesToFile WriteOnlyWhenDifferent="true" and the Copy task's SkipUnchangedFiles="true" exist for exactly this reason.
incremental.binlog (second build, nothing changed)
Orders.Domain
GenerateVersionInfo ran (no Inputs/Outputs, rewrites VersionInfo.g.cs)
CoreCompile ran 9.8 s (input VersionInfo.g.cs is newer)
reference assembly CHANGED? -- the timestamp attribute is in the metadata, so yes
|
v
Orders.Application CoreCompile ran (reference changed)
Orders.Api CoreCompile ran (reference changed)
... every dependant recompiles
The SDK produces reference assemblies (ProduceReferenceAssembly, on by default): a copy of each assembly containing only its public surface. Dependants compile against it, and it is only rewritten when the public surface changes. So editing a method body in Orders.Domain recompiles Orders.Domain only. An assembly attribute containing a timestamp changes the metadata on every build, defeats that optimisation and turns one project's rebuild into a whole-solution rebuild.
| Cause | What you see in the log | Fix |
|---|---|---|
Custom target with no Inputs/Outputs |
Target runs in every build | Declare Inputs and Outputs |
| Generated file rewritten every build | CoreCompile ran: generated file is newer |
WriteOnlyWhenDifferent="true"; stable content locally |
| Timestamps or build numbers in assembly attributes | Every project recompiles, reference assemblies change | Set real values only in CI builds |
CopyToOutputDirectory="Always" |
Copies every build; Visual Studio's fast up-to-date check always says "not up to date" | PreserveNewest |
| Globs over large folders | Long evaluation time per project | DefaultItemExcludes, Compile Remove, or move the folder out |
Exec running npm install or similar |
A long Exec task in every build |
Inputs on package-lock.json, Outputs on a stamp file |
| Expensive analysers or source generators | Csc is long; ReportAnalyzer shows one package dominating |
Fix or replace it, or run it in a separate CI job |
| One huge project at the root of the graph | Timeline shows other nodes idle while it compiles | Split it, or remove references that aren't needed |
| Restore every time in CI | Long Restore with downloads |
Cache ~/.nuget/packages keyed on lock files; --no-restore after one restore step |
With -p:ReportAnalyzer=true, the Csc task in the log lists "Total analyzer execution time" and "Total generator execution time", then the time per analyser and generator assembly and per rule. Options when one dominates:
.editorconfig if they add little.-p:RunAnalyzers=false skips analysers entirely. Don't make that the default.A source generator that isn't incremental (one that uses the old ISourceGenerator API instead of IIncrementalGenerator) can also slow the IDE badly, not just the build.
dotnet build-server shutdown stops them when they hold stale state, for example after changing a custom task assembly.-nodeReuse:false or UseSharedCompilation=false to avoid locked files; measure before copying that setting locally.RestoreUseStaticGraphEvaluation=true makes restore evaluate projects faster, and dotnet build -graph builds from the static project graph. Measure both before adopting them.dotnet build -bl:full.binlog (clean or normal build).dotnet build -bl:incremental.binlog immediately again, with no changes.incremental.binlog, search $target CoreCompile. Every one should say "Skipping target". For each one that ran, read the reason and follow the named input back to the target that wrote it.full.binlog, open the target and task summaries and the timeline. Note the top five targets and tasks, and whether the timeline shows idle nodes.Csc dominates, rebuild with -p:ReportAnalyzer=true -bl and read the analyser breakdown.binlogtool redact command) before sharing outside the team.Directory.Build.targets and custom tasks for build-time impact, because they run in every project..NET 11 (due in November 2026, an STS release with 24 months of support, currently in preview and release candidates) ships a newer MSBuild and compiler. The binary log format and the diagnosis approach stay the same; after an SDK upgrade, compare a binary log from before and after to catch any change in build time.
GenerateVersionInfo target from the code example to Directory.Build.targets in a solution with two projects, one referencing the other.-bl:first.binlog and -bl:second.binlog. In the second log, search $target CoreCompile and read why it ran in both projects.dotnet build -clp:PerformanceSummary and compare the summary with the viewer's target summary.-p:ReportAnalyzer=true -bl, and find the per-analyser times under the Csc task.<None Include="data.json" CopyToOutputDirectory="Always" /> and see the copy happen on every build; change it to PreserveNewest and build again.<!-- BAD: runs every build and always rewrites the file, so CoreCompile reruns everywhere -->
<Target Name="GenerateVersionInfo" BeforeTargets="CoreCompile">
<WriteLinesToFile File="$(IntermediateOutputPath)VersionInfo.g.cs"
Lines="[assembly: System.Reflection.AssemblyMetadata("BuiltAt", "$([System.DateTime]::UtcNow.ToString('o'))")]"
Overwrite="true" />
<ItemGroup>
<Compile Include="$(IntermediateOutputPath)VersionInfo.g.cs" />
</ItemGroup>
</Target>
<!-- FIXED: stable content locally, real values only on CI, written only when different -->
<PropertyGroup>
<BuildCommit Condition="'$(BuildCommit)' == ''">local</BuildCommit>
</PropertyGroup>
<Target Name="GenerateVersionInfo" BeforeTargets="CoreCompile">
<WriteLinesToFile File="$(IntermediateOutputPath)VersionInfo.g.cs"
Lines="[assembly: System.Reflection.AssemblyMetadata("Commit", "$(BuildCommit)")]"
Overwrite="true"
WriteOnlyWhenDifferent="true" />
<ItemGroup>
<Compile Include="$(IntermediateOutputPath)VersionInfo.g.cs" />
<FileWrites Include="$(IntermediateOutputPath)VersionInfo.g.cs" />
</ItemGroup>
</Target>
# CI guard: a no-change rebuild must stay fast
dotnet build -c Release
start=$(date +%s)
dotnet build -c Release --no-restore -bl:incremental.binlog
elapsed=$(( $(date +%s) - start ))
echo "No-change build: ${elapsed}s"
[ "$elapsed" -lt 60 ] || { echo "Incremental build regression"; exit 1; }
In CI, pass the commit with -p:BuildCommit=$GITHUB_SHA (or $(Build.SourceVersion) in Azure Pipelines). FileWrites tells dotnet clean to remove the generated file.
Mistake: guessing instead of measuring. "It's the antivirus" or "we need faster agents" might be partly true, but without evidence the real cause stays and the money is wasted. Instead: capture a binary log of a full build and of a no-change rebuild, and let the timings and "why did this run" messages point to the cause.
Mistake: reading -v:diag console output.
It's huge and flat, so finding why one target ran is slow, and the console log loses structure.
Instead: capture with -bl and use the Structured Log Viewer's search and summaries. Replay the binary log as text later if you really need it.
Mistake: writing custom targets without Inputs and Outputs.
The target runs on every build, and if it writes a file that feeds the compiler, every project recompiles.
Instead: declare Inputs and Outputs, and write generated files with WriteOnlyWhenDifferent="true" so the timestamp only changes when the content does.
Mistake: putting timestamps or build numbers into assembly attributes in local builds.
The metadata changes every build, so reference assemblies change and every dependant recompiles.
Instead: use stable values locally and pass the real commit or build number only in CI, for example -p:BuildCommit=$GITHUB_SHA.
Mistake: using CopyToOutputDirectory="Always".
The file is copied on every build and Visual Studio's fast up-to-date check always rebuilds the project.
Instead: use PreserveNewest.
Mistake: deleting an expensive analyser without telling anyone.
Builds get faster, but the bugs it caught (often security issues) come back unnoticed.
Instead: measure it with ReportAnalyzer, update or tune it, or move it to a dedicated CI job, and keep the SDK security rules in every build.
Mistake: sharing binary logs publicly. They can contain tokens, feed credentials and connection strings from properties and environment variables. Instead: keep them as restricted CI artefacts, and redact them before sharing outside the team.
Mistake: fixing it once without a guard. Build performance decays one target or package at a time, so the regression returns within months. Instead: add a CI job that times a no-change rebuild and fails above a threshold, and track full build time on a dashboard.
Mistake: testing only the command line when developers complain about Visual Studio.
Visual Studio has its own fast up-to-date check, so it can rebuild projects that dotnet build skips.
Instead: turn on verbose up-to-date check logging in Visual Studio and read its reason, or capture a log from the IDE with MSBUILDDEBUGENGINE=1.
Build performance decays quietly: one custom target, one analyser package, one glob at a time. A senior engineer treats it like production performance: measure with the right tool (binary logs, analyser reports), find the mechanism (an input that changes every build, a serial bottleneck, an expensive analyser), fix the cause, then guard it with a metric in CI. They also weigh trade-offs openly, for example running an expensive security analyser nightly instead of in every inner-loop build, rather than just deleting it.
MSBuild is the build engine under dotnet build, Visual Studio and Rider, and a .csproj is an MSBuild file. Properties are named values such as $(Configuration). Items are lists with metadata, such as @(Compile) holding every .cs file. Targets are named steps, ordered with DependsOnTargets, BeforeTargets and AfterTargets. Each target runs tasks, the real units of work, such as Csc to compile or Copy. The build has two phases. Evaluation reads imports, properties and items. Execution runs the targets. Targets that declare Inputs and Outputs are skipped when nothing has changed, which is what makes builds incremental.
Directory.Build.props and Directory.Build.targets are ordinary MSBuild files that the SDK imports automatically into every project below them. For each project, MSBuild searches upwards and uses the nearest one it finds. .props is imported early, before the project's own content, so it's for default properties and items: target framework, Nullable, analysers, package metadata. Projects can still override those values. .targets is imported late, after the project, so it's for targets and logic that need final values. Only the nearest file is picked up. To layer them, the inner file imports the outer one with GetPathOfFileAbove. I debug them with dotnet msbuild -preprocess or a binary log.
I'd treat the build as a product, with a contract that lives in files at the repo root:
global.json for the SDK.Directory.Build.props/.targets for framework, nullable, analysers and output layout.Directory.Packages.props with transitive pinning for versions.nuget.config with source mapping, plus lock files..editorconfig for style and analyser severities.Project files stay tiny. A small platform group owns those files through CODEOWNERS, and there's a documented exception process for overrides and suppressions. CI uses shared templates that run exactly what developers run locally. The warning policy ratchets stricter over time. SDK, framework and package upgrades arrive as automated, grouped pull requests. I'd measure it all (build time, warnings, drift, time to patch) so governance helps teams instead of just blocking them.
How to tell it (STAR)
**Situation:** Our [70-project] solution at [company] had slowed from [2] to [15] minutes, and even no-change builds recompiled everything. **Task:** I volunteered to find the cause, since it was costing every developer [hours] a week. **Action:** I compared binary logs of two consecutive builds and saw CoreCompile rerunning because a shared target wrote a timestamp into generated code every time. I made the target write stable content locally and only when it changed, measured analysers with ReportAnalyzer, and moved one expensive analyser to a nightly job. I added a CI guard on no-change build time. **Result:** Full builds dropped to [about 3] minutes and no-change builds to [about 10] seconds, and the guard has caught [one] regression since.
Last updated 1 Nov 2026.