Source Generators: The Build Performance Killer Nobody Warned You About

Source Generators: The Build Performance Killer Nobody Warned You About

You add a NuGet package. Build time jumps from 2 seconds to 8. You rebuild a second time: still 8 seconds. You change one line of code: 8 seconds again. The package description said nothing about this. You just quietly accepted a 300% tax on every build for the rest of the project’s life.

That package ships a source generator.

Source generators are one of the most powerful additions to the .NET compiler platform. They are also one of the most invisible performance costs in modern .NET development. Everyone writes about what they can do. Nobody writes about what they cost.

What Source Generators Actually Do (On Every Build)

The mental model most developers have: source generators run once, generate some code, done. That is wrong.

Source generators run as part of the Roslyn compilation pipeline. Every time you build (full build, incremental build, background build triggered by saving a file), every registered generator runs. Not optionally. Not conditionally. Every time.

A typical mid-sized .NET solution in 2025 has more active source generators than you think. Add them up:

PackageGenerator
Microsoft.Extensions.LoggingLoggerMessageGenerator
System.Text.JsonJsonSerializerSourceGenerator
AutoMapper.Extensions.Microsoft.DependencyInjectionMapping generator
MapperlyMapper generator
Microsoft.NET.Sdk.MauiMultiple generators
Any DI frameworkRegistration generator
Any gRPC toolingService/client generator

Eight to twelve active generators per project is not unusual. Each one is a Roslyn plugin executing against your full syntax tree on every build.

The Two Kinds of Source Generators

Not all source generators are created equal. This distinction matters enormously for build performance.

ISourceGenerator: the original API from .NET 5. Receives the full compilation. Runs completely on every build. No caching, no incremental logic. If you have one of these, you pay full price every time regardless of what changed.

IIncrementalGenerator: introduced in .NET 6. Uses a pipeline model that tracks which inputs actually changed. If your code change does not affect the generator’s inputs, the generator produces cached output and skips real work. Used correctly, incremental generators approach zero cost on unchanged code.

The catch: many popular NuGet packages still ship ISourceGenerator implementations. The API is not deprecated. There is no warning when you install a non-incremental generator. You find out at build time.

Measuring the Damage

You cannot fix what you cannot measure. Fortunately, MSBuild gives you everything you need.

Binary Log

dotnet build -bl

This produces msbuild.binlog in your project directory. Open it with MSBuild Structured Log Viewer. Search for GeneratorDriver or RunGenerators. You will see each generator, its execution time, and how often it ran.

A real example from a project I worked on:

RunGenerators (net9.0)
  ├── JsonSerializerSourceGenerator    42ms
  ├── LoggerMessageGenerator            8ms
  ├── MapperlyGenerator               890ms   ← problem
  └── AutoMapperGenerator             340ms   ← problem

That is 1.2 seconds per build from two mapping generators. At 300 builds per day (realistic for an active developer with file-save triggers), that is 6 minutes of daily waste. Per developer. On a 5-person team: 30 minutes every day.

Roslyn Generator Timing

For finer granularity, set the ReportAnalyzer property:

<PropertyGroup>
  <ReportAnalyzer>true</ReportAnalyzer>
</PropertyGroup>

Build output will include per-generator timing in milliseconds. Slower and less detailed than binlog, but useful for quick checks without installing additional tools.

Multi-Targeting Multiplies Everything

Here is the cost multiplier nobody mentions in the documentation:

<TargetFrameworks>net8.0;net9.0</TargetFrameworks>

Every source generator runs once per target framework. Two targets: double the cost. Three targets: triple. That MapperlyGenerator eating 890ms? Now it costs 1,780ms on every build. Every registered generator, every target, every time.

Library authors supporting net6.0;net7.0;net8.0;net9.0 and shipping a non-incremental generator are imposing a 4× multiplier on every consumer of their package.

Hot Reload: The Silent Incompatibility

Source generators and .NET Hot Reload have a complicated relationship.

Hot Reload works by applying incremental changes to a running process without a full restart. Source generators complicate this because the generated code might need to change when your code changes, and the Hot Reload mechanism cannot always determine whether that is safe.

The result: if your project has source generators that interact with the code you changed, Hot Reload silently falls back to a full rebuild and restart. The dotnet watch output tells you this happened:

warn: Hot reload of changes succeeded but some changes required application restart.

You lose the instant feedback loop that makes Hot Reload valuable. With several active generators, you may find Hot Reload effectively never works for your use case.

To diagnose which generators break Hot Reload, temporarily remove them one by one and observe whether dotnet watch starts applying changes without restart.

IDE Latency: The IntelliSense Tax

Source generators run in the background inside Visual Studio and Rider to keep generated code available for IntelliSense, navigation, and error highlighting. This is a continuous background process, not just a build-time concern.

Non-incremental generators re-run whenever the IDE detects a change to your syntax tree. Type a character, save a file, the generator chain kicks off. If your generators are slow, you experience this as:

  • IntelliSense suggestions appearing late or disappearing temporarily
  • “Analyzing…” spinners that block navigation
  • Go to Definition jumping to stale generated code
  • Intermittent red squiggles on valid code

This is hard to attribute directly because IDEs do not surface per-generator timings in their UI. The binlog approach does not help here either since that measures CLI builds. Your best signal is disabling generators one at a time via <Analyzer Remove="..." /> and observing whether IDE responsiveness improves.

When Source Generators Are Worth It

Source generators are not the problem. The problem is using them without understanding the trade-off.

Clearly worth it:

  • [LoggerMessage] source generator: eliminates allocation on every log call, compiler-enforced message templates. The runtime savings at high throughput far outweigh the build cost.
  • System.Text.Json source generator: AOT-compatible serialization, zero reflection at runtime. Required for Native AOT scenarios, significant throughput improvement in hot paths.
  • Strongly-typed ID generators (like StronglyTypedId): compile-time correctness guarantee, zero runtime cost.

Often not worth it:

  • Mapping generators for simple DTOs where a hand-written mapper takes 20 lines and compiles instantly
  • DI registration generators that save writing services.AddScoped<IFoo, Foo>() a few times
  • Boilerplate generators for code that changes rarely and where T4 templates or a one-time script would suffice

The deciding question: does this generator eliminate runtime cost, enforce correctness at compile time, or enable something impossible without it? If the answer is “it saves me from writing some repetitive code,” a T4 template or a code snippet achieves the same result without touching every build.

How to Check Whether a Generator Is Incremental

Before adding a package that ships a source generator, check whether its generator implements IIncrementalGenerator.

The fast way: look at the package’s GitHub repository and search for IIncrementalGenerator vs ISourceGenerator. If the generator implements ISourceGenerator, it is non-incremental.

The programmatic way: inspect the assembly directly.

using System.Reflection;

var assembly = Assembly.LoadFrom("path/to/generator.dll");
var generatorTypes = assembly.GetTypes()
    .Where(t => t.GetInterfaces()
        .Any(i => i.FullName == "Microsoft.CodeAnalysis.ISourceGenerator"
               || i.FullName == "Microsoft.CodeAnalysis.IIncrementalGenerator"));

foreach (var type in generatorTypes)
{
    var isIncremental = type.GetInterfaces()
        .Any(i => i.FullName == "Microsoft.CodeAnalysis.IIncrementalGenerator");
    Console.WriteLine($"{type.Name}: {(isIncremental ? "Incremental" : "Non-incremental")}");
}

If the generator is non-incremental and the package is popular, check whether there is an open issue or PR for the migration. Many maintainers have not made the switch simply because nobody asked.

Mitigation Strategies

When you cannot remove a generator but need to reduce its cost:

Emit and cache generated files:

<PropertyGroup>
  <EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
  <CompilerGeneratedFilesOutputPath>$(BaseIntermediateOutputPath)Generated</CompilerGeneratedFilesOutputPath>
</PropertyGroup>

This writes generated files to disk. You can commit them to source control and exclude the generator from CI builds when inputs have not changed. Adds complexity; worth it for slow generators on large projects.

Isolate generators to dedicated projects:

Split your solution so that the code triggering expensive generators lives in a dedicated project that changes rarely. The generator only runs when that project rebuilds, not on every change to your main project.

Disable generators in specific configurations:

<ItemGroup Condition="'$(Configuration)' == 'Debug'">
  <Analyzer Remove="@(Analyzer)" Condition="'%(Filename)' == 'SlowGenerator'" />
</ItemGroup>

Use this sparingly. It can cause the Debug build to diverge from Release in ways that mask real errors.

Profile before optimizing:

Measure with binlog first. The generator you suspect is slow is often not the actual problem. The one you never thought about frequently is.

The Bigger Picture

Source generators sit at the intersection of a real tension in modern .NET: zero runtime cost requires paying that cost somewhere else, and “somewhere else” is build time and IDE responsiveness.

The .NET ecosystem has moved fast on source generators since .NET 5. The migration from ISourceGenerator to IIncrementalGenerator is ongoing but incomplete. Many widely-used packages still ship non-incremental generators because the migration requires significant effort and the existing API works.

As a consumer, the tools are available to you. Measure with binlog. Understand whether each generator pays its way. Push back on packages that impose non-incremental generators for convenience features.

The build time you save is your own.

Profile first. Then optimize.

Comments

VG Wort