Parallel, Graph and Server Builds

This section documents MSBuild 18.9, the .NET SDK 10.0.402 (with its dotnet CLI) and NuGet 7.9, as published in the MSBuild, .NET and NuGet documentation on learn.microsoft.com, which is the reference these pages are written and verified against. Material about .NET 11 / NuGet 8.0 is still in preview and is always flagged as such — never presented as baseline.

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The C# Reference bibliography lists the reference material consulted while preparing these pages.

A single project builds its targets one after another, but a repository of many projects can build several projects at once, and successive builds can reuse warm processes. MSBuild offers three mechanisms: worker nodes (parallel processes, -m), the static graph (-graph, an up-front dependency graph instead of discovering references while building), and MSBuild Server (a long-lived process that keeps evaluation caches between dotnet commands). This page measures them on Ledgerline with MSBuild 18.9 (.NET SDK 10.0.402) on a 4-core Linux machine.

Worker nodes and -maxCpuCount

With -m[:n] MSBuild starts up to n worker processes ("nodes"); each project is assigned to a free node, and within a node a project’s targets run serially. dotnet build passes -maxcpucount (one node per core) by default; msbuild.exe builds on one node unless told otherwise. A rebuild of the Ledgerline solution (six projects, one of them with three TFMs):

dotnet build Ledgerline.slnx --no-restore --no-incremental -m:1 -tl:off -v:q
dotnet build Ledgerline.slnx --no-restore --no-incremental -m:4 -tl:off -v:q
Time Elapsed 00:00:03.27
Time Elapsed 00:00:02.06

-ds (-detailedSummary) prints, at normal verbosity, which configuration ran where and for how long — the first tool to read when a parallel build is slower than expected:

         ============================== Build Hierarchy (IDs represent configurations) =====================================================
         Id                  : Exclusive Time   Total Time   Path (Targets)
         -----------------------------------------------------------------------------------------------------------------------------------
         0                   : 0.057s           1.777s       ~/ledgerline/Ledgerline.slnx (Rebuild)
         | 1                 : 1.026s           1.026s       ~/ledgerline/samples/Ledgerline.Report/Ledgerline.Report.csproj (Rebuild)
         | 4                 : 0.344s           0.344s       ~/ledgerline/src/Ledgerline.Templates/Ledgerline.Templates.csproj (Rebuild)
         | 3                 : 0.550s           1.348s       ~/ledgerline/src/Ledgerline.Cli/Ledgerline.Cli.csproj (Rebuild)
…
         ============================== Node Utilization (IDs represent configurations) ====================================================
         Timestamp:            1       2       4       3        Duration   Cumulative
         -----------------------------------------------------------------------------------------------------------------------------------
         639283777188399085:   0       x       x       x        0.041s     0.041s
         639283777188807904:   1       x       x       x        0.050s     0.091s .
         639283777189308164:   |       2       3       4        0.240s     0.330s ....
…

The MSBuild task builds its Projects on several nodes when BuildInParallel is true, its default; within one project, everything else is sequential.

Node reuse

Worker nodes stay alive after the build (-nodeReuse:true, the default) so the next build skips their start-up. After a dotnet build of the solution the processes are still there:

ps -eo args | grep nodemode
      3 MSBuild.dll ... /nodemode:1 /nodeReuse:true
      1 <dotnet>/sdk/10.0.402/MSBuild /nologo /nodemode:2 /nodereuse:True /low:False /parentpacketversion:4

(counted with uniq -c; /nodemode:1 are worker nodes). On a CI agent that must not leave processes behind, or after changing a custom task’s DLL, use -nodeReuse:false (-nr:false) or shut them down with dotnet build-server shutdown.

Static graph builds

By default, MSBuild discovers project references while building: when a project reaches ResolveProjectReferences, it calls the MSBuild task on its references, pausing until they are built. With -graph (-graphBuild), MSBuild first evaluates every project, builds the dependency graph from ProjectReference items, and then schedules the graph bottom-up.

flowchart LR subgraph REC["Default: recursive traversal"] R1["Report: start Build"] -->|"ResolveProjectReferences calls the MSBuild task"| L1["Ledgerline: Build"] L1 -->|"returns outputs"| R2["Report: continue to CoreCompile"] end subgraph GRAPH["-graph: static graph"] E["Evaluate all projects, read ProjectReference items"] --> G["Build the graph"] G --> L2["Ledgerline: Build (leaf first)"] L2 --> R3["Report: Build (references already built)"] end
dotnet build Ledgerline.slnx -graph --no-restore -tl:off -v:m
Static graph construction started.
Static graph loaded in 0.566 seconds: 9 nodes, 11 edges
  Ledgerline -> ~/ledgerline/src/Ledgerline/bin/Debug/netstandard2.0/Ledgerline.dll
  Ledgerline.Report -> ~/ledgerline/samples/Ledgerline.Report/bin/Debug/net10.0/Ledgerline.Report.dll
…
  Ledgerline.Tests -> ~/ledgerline/tests/Ledgerline.Tests/bin/Debug/net10.0/Ledgerline.Tests.dll
Build succeeded.

The graph has nine nodes for six projects because the multi-targeted library contributes an outer node and three inner ones. A graph build is only correct when the graph is complete: a project that builds another project which it does not declare as a ProjectReference would build it outside the graph.

Isolated builds

-isolate (-isolateProjects) turns that rule into an error: a project may only build the targets of the references it declares, with the targets it declares for them through ProjectReferenceTargets items. Three plain projects — Report.proj references Ledger.proj and also calls Audit.proj directly:

<Project>
  <ItemGroup>
    <ProjectReference Include="Ledger.proj" />
    <!-- Tells the graph which targets this project calls on its references. -->
    <ProjectReferenceTargets Include="Build" Targets="Build" />
  </ItemGroup>
  <Target Name="Build">
    <MSBuild Projects="@(ProjectReference)" Targets="Build" />
    <!-- Not declared as a reference: forbidden under -isolate. -->
    <MSBuild Projects="Audit.proj" Targets="Build" />
    <Message Importance="high" Text="Report built" />
  </Target>
</Project>
dotnet msbuild Report.proj -nologo -v:m
dotnet msbuild Report.proj -graph -isolate -nologo -v:m
  Ledger built
  Audit built
  Report built
Static graph construction started.
Static graph loaded in 0.103 seconds: 2 nodes, 1 edges
  Ledger built
~/graph/Report.proj(10,5): error : MSB4254: The MSBuild task is building project(s) ""~/graph/Audit.proj"" which are not specified in the ProjectReference item. In isolated builds this probably means that the references are not explicitly specified as a ProjectReference item in "~/graph/Report.proj"  ~/graph/Report.proj

Without the ProjectReferenceTargets item, even the declared reference fails, with MSB4252 ("…the build result for the built project is not in the engine cache… the reference was called with a target which is not specified in the ProjectReferenceTargets item"). The SDK declares ProjectReferenceTargets for its own targets, which is why SDK projects build under -graph -isolate without changes; custom targets that call references need their own items.

Isolation is the prerequisite for result caches: -orc:<file> (-outputResultsCache) writes a project’s results, and -irc:<files> (-inputResultsCaches) lets a later build use them instead of rebuilding the references — a building block for distributed build systems, documented only as switches.

MSBuild Server

MSBuild Server is a long-lived process (/nodemode:8) that hosts the build itself, so successive dotnet build commands skip start-up and reuse evaluation caches. It is off by default on .NET 10 and enabled with DOTNET_CLI_USE_MSBUILD_SERVER=true (or 1); it is on by default from the .NET 11 SDK, which is a preview (DOTNET_CLI_USE_MSBUILD_SERVER=false opts out; the .NET 11 CLI also stops overwriting MSBUILDUSESERVER with 0, so that MSBuild variable can be used directly). -mt builds always use it. It helps developer inner loops, not CI agents that build once.

DOTNET_CLI_USE_MSBUILD_SERVER=1 dotnet build Ledgerline.slnx --no-restore -tl:off -v:q
ps -eo args | grep "nodemode:8"
dotnet build-server shutdown
      1 <dotnet>/sdk/10.0.402/MSBuild /nologo /nodemode:8
Shutting down MSBuild server...
Shutting down VB/C# compiler server...
VB/C# compiler server shut down successfully.
MSBuild server shut down successfully.

dotnet build-server shutdown stops MSBuild Server, the worker nodes and the Roslyn compiler server (--msbuild, --vbcscompiler and --razor select one). -nodeReuse:false disables the server for one build, because the server is a form of node reuse; -help, -version and binary-log replays never use it. A -v:diag log or a binary log records whether the server was started, reused or bypassed.

Common mistakes

  • msbuild.exe without -m on CI — one node; dotnet build already parallelises.

  • Shared output paths between projects — parallel builds race on the same files (MSB3026/MSB3027 on Windows).

  • Custom targets that build other projects with the MSBuild task without declaring them — wrong under -graph, errors under -isolate.

  • Stale task DLLs held by reused nodes — dotnet build-server shutdown or -nr:false.

  • Expecting MSBuild Server on .NET 10 by default — it is opt-in until .NET 11.