When System.Text.Json Source Generation Is Worth It in ASP.NET Core
System.Text.Json source generation isn't a universal speedup — it's a way to make serialization trim‑safe and predictable. Here’s how to scope it to the DTOs that matter.
18 Sept 2025, 07:20 UTC

Your Minimal API works fine in development, then you flip on trimming for a smaller container image and the publish log fills with IL2026 and IL3050 warnings pointing at JSON serialization. Or cold‑start latency matters and you'd rather not pay reflection costs on the first requests. This is the practical case for System.Text.Json source generation — not a blanket performance trick, but a way to move serializer metadata discovery from runtime reflection to compile‑time generated code.
The thesis: source‑generate the stable DTO set your endpoints actually use, and leave genuinely dynamic payloads on the reflection path. Trying to source‑generate everything usually creates more friction than it removes.
What source generation actually changes
By default, System.Text.Json inspects your types with reflection the first time it serializes them, builds metadata, and caches it. That works well, but it has two costs: runtime work at startup or first use, and a dependency on reflection that trimmers and Native AOT toolchains can't fully analyze — hence the warnings.
With source generation, a Roslyn generator runs at compile time and emits serializer code for the types you list in a JsonSerializerContext. The metadata is known ahead of time, so the trimmer sees exactly what's used. A side effect worth wanting: if you forget to register a DTO, the failure surfaces as an explicit configuration problem instead of a late runtime surprise.
A worked example
Assume .NET 8 or later (attribute shapes and diagnostics improved across releases, so pin snippets to your SDK). Two DTOs and a context:
public record CreateOrderRequest(string Sku, int Quantity);
public record OrderResponse(Guid Id, string Sku, int Quantity, decimal Total);
[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
[JsonSerializable(typeof(CreateOrderRequest))]
[JsonSerializable(typeof(OrderResponse))]
public partial class AppJsonContext : JsonSerializerContext { }
Register it in Program.cs so Minimal APIs use the generated metadata:
builder.Services.ConfigureHttpJsonOptions(options =>
{
options.SerializerOptions.TypeInfoResolverChain.Insert(0, AppJsonContext.Default);
});
Run this in your API project's startup code; no special permissions needed. TypeInfoResolverChain is the .NET 8+ way to combine resolvers — the generated context is consulted first, and anything it doesn't know falls through to the default reflection resolver. On .NET 6/7 you'd set TypeInfoResolver instead, which replaces rather than chains. That difference matters if you copy snippets between projects.
To verify: exercise both endpoints and confirm payloads round‑trip with the expected camelCase names. Then run dotnet publish -c Release -p:PublishTrimmed=true (or your AOT publish profile) and compare the warning list against the pre‑change baseline. The JSON‑related trim warnings for your registered DTOs should be gone.
Where it doesn't fit
Skip source generation — or scope it narrowly — when endpoints accept arbitrary JsonNode/JsonDocument payloads, when contracts come from plugins, or when shapes genuinely change per request. Those are exactly the cases reflection handles well.
Also watch the feature interactions: polymorphism ([JsonPolymorphic]), custom converters, required members, and reference handling must be configured consistently between the attributes on the context and any runtime JsonSerializerOptions you pass elsewhere. Mixing the generated path and the reflection path for the same type with misaligned options produces subtle serialization differences that are miserable to debug. Pick one resolver chain and route everything through it.
The honest trade‑off
Don't adopt this for a guaranteed throughput win. Whether you see one depends on payload size, endpoint churn, and cold‑start sensitivity — measure with your own payloads before claiming anything. The more reliable benefits are operational: fewer trim warnings, predictable linker behavior, and a viable path to Native AOT or size‑constrained deployments. Native AOT itself is a bigger commitment — it constrains reflection‑heavy libraries, dynamic proxies, and some framework patterns — so treat it as a deliberate deployment choice, not a default.
The ongoing cost is maintenance: every new DTO needs a [JsonSerializable] entry. Make that failure mode visible. Add a test DTO without registering it, observe the error (with the resolver chain above it silently falls back to reflection, which may or may not be what you want — consider a test asserting your hot DTOs resolve from AppJsonContext), and document the fix in your team's onboarding notes.
Actionable closing
Start with one service: list the request/response DTOs on your hottest endpoints, generate a context for just those, register it via ConfigureHttpJsonOptions, and publish with trimming enabled to compare warnings. If the list of DTOs is stable and the warnings disappear, expand. If your endpoints are mostly dynamic JSON, close this tab and keep reflection — that's a legitimate answer too.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.