Introduction

If you have <PublishTrimmed>true</PublishTrimmed> in a Blazor WebAssembly project and consume JSON via HttpClient.GetFromJsonAsync<T> or JsonSerializer.Deserialize<T> without declaring a JsonSerializerContext, the IL trimmer can silently delete the parameterless constructors your reflection-based deserialization depends on — producing a DeserializeNoConstructor error at runtime that no build check catches.

This article walks through:

  1. Why the trimmer does this

  2. Why IsTrimmable=false is a fragile fix

  3. How System.Text.Json source generation solves the problem correctly

  4. Three diagnostic patterns to catch this class of bug earlier

The scenario is drawn from a real production incident on SmartTaxCalc.in, a Blazor WebAssembly Indian tax-calculator suite. The bug broke every calculator on the site — HRA, income tax, NRI residency, capital gains, all 38 of them — for four days before a user finally reported that the Calculate button was silent.

The setup

Two projects in the SmartTaxCalc.in solution running .NET 10:

The Core .csproj contains one line that looks innocuous:

<PropertyGroup>
  <TargetFramework>net10.0</TargetFramework>
  <IsTrimmable>true</IsTrimmable>
</PropertyGroup>

Every optimization guide for Blazor WASM recommends marking your own assemblies as trimmable to shrink the payload. Small assembly, big win, disciplined choice.

The Core data model is standard:

public sealed record TaxRulesConfiguration
{
    [JsonPropertyName("version")]
    public string Version { get; init; } = "";

    [JsonPropertyName("financialYears")]
    public IReadOnlyList<FinancialYear> FinancialYears { get; init; } = [];
}

Loaded at startup:

_rulesConfig = await _httpClient.GetFromJsonAsync<TaxRulesConfiguration>(
    "data/tax-rules.json",
    new JsonSerializerOptions { PropertyNameCaseInsensitive = true });

dotnet build: clean. dotnet publish: clean. Deployed. Users hit the Calculate button. Console error:

Error: DeserializeNoConstructor, JsonConstructorAttribute,
TaxPlanner.Core.Models.TaxRulesConfiguration
Path: $ | LineNumber: 0 | BytePositionInLine: 1

Why the trimmer deleted the constructor

TrimMode=partial means: aggressively trim assemblies marked IsTrimmable=true. The trimmer walks from every reachable entry point (Program.Main, exposed public APIs) and deletes anything not reached by static analysis.

Here's what it saw with TaxRulesConfiguration:

Reflection is invisible to the trimmer. From its perspective the constructor was unreachable code. Deleted. And the trimmer emits no warning because it did exactly what static analysis said to do.

The runtime deserializer then walked the reflection path, found no public parameterless constructor, no [JsonConstructor] attribute, no custom converter, and threw. dotnet build and dotnet publish both stay green because the bug is entirely at runtime.

Fix attempt: IsTrimmable=false on the Core assembly

Obvious first move:

<IsTrimmable>false</IsTrimmable>

Deployed. Bundle size for Core.wasm jumped 12 KB → 49 KB (the constructor IL + type metadata the trimmer had been eating). That size delta is itself a valuable debugging signal: a jump of tens of KB when flipping IsTrimmable=false on a small assembly means reflection targets were being silently stripped. Same-size-before-and-after would mean the trimmer had found nothing to trim; the jump proves it was doing real work.

The identical runtime error persisted through this "fix," but only because of a caching layer:

Two lessons from this false-negative:

  1. IsTrimmable=false likely was the correct code-level fix at that moment. The verification failed because of stale cached assets, not the fix itself.

  2. IsTrimmable=false remains a fragile defense — you're depending on the trimmer's reachability analysis AND on your CDN cache invalidation both cooperating.

The proper fix is one that doesn't depend on the trimmer's cooperation at all.

The real fix: System.Text.Json source generation

Microsoft's official recommendation for trim-safe and AOT-safe JSON in .NET 8+ is source generation. Instead of runtime reflection walking your types, the compiler emits the deserialization code at build time. The trimmer sees the generated code as ordinary reachable IL and preserves it correctly.

Step 1: declare a partial JsonSerializerContext in your Core library:

using System.Text.Json.Serialization;
using TaxPlanner.Core.Models;

namespace TaxPlanner.Core.Json;

[JsonSourceGenerationOptions(
    PropertyNameCaseInsensitive = true,
    ReadCommentHandling = System.Text.Json.JsonCommentHandling.Skip)]
[JsonSerializable(typeof(TaxRulesConfiguration))]
public partial class TaxRulesJsonContext : JsonSerializerContext { }

The [JsonSerializable(typeof(T))] attribute triggers codegen. You list every root type you want serialized or deserialized. The generator walks the object graph — TaxRulesConfiguration contains a List<FinancialYear>, each FinancialYear contains a TaxRegime and a Deductions, etc. — and emits deserializer code for every nested type. No need to enumerate nested types explicitly.

Step 2: change the call site to the source-gen overload:

// Before (reflection path — broken under PublishTrimmed):
_rulesConfig = await _httpClient.GetFromJsonAsync<TaxRulesConfiguration>(
    "data/tax-rules.json",
    new JsonSerializerOptions { PropertyNameCaseInsensitive = true });

// After (source-gen path — trim-safe by construction):
_rulesConfig = await _httpClient.GetFromJsonAsync(
    "data/tax-rules.json",
    TaxRulesJsonContext.Default.TaxRulesConfiguration);

The overload that takes a JsonTypeInfo<T> (which TaxRulesJsonContext.Default.TaxRulesConfiguration is) selects the generated deserializer instead of the reflection one.

After deployment: Core.wasm went from 49 KB (metadata preserved via IsTrimmable=false) to 156 KB (metadata + generated deserializer). About 30 KB brotli-compressed, downloaded once, cached immutable. Every calculator worked immediately — you can verify the fix live at smarttaxcalc.in/tools/hra-calculator/ or any of the other 37 calculators on the site (income tax, capital gains, NRI DTAA, gratuity, and more).

Three diagnostic patterns worth internalising

1. Small assembly, big size jump on IsTrimmable=false — if flipping trim off adds tens of KB of uncompressed WASM to an app-owned assembly, that assembly has reflection-consumed types the trimmer was silently deleting. Investigate those types before believing IsTrimmable=false is your fix. Same-size-before-and-after means trim was finding nothing trimmable there.

2. dotnet build + dotnet publish green does not mean runtime-correct under trim — the trimmer emits no warning for stripping a reflection-only constructor because from its perspective the code was unreachable. Trim regressions surface only in the browser, only when the deserialization fires. Any change touching assembly boundaries, IsTrimmable, or DI graph must be verified in a real browser before it's considered done.

3. Reflection-consumed types → source-gen or nothing[JsonSerializable] + JsonSerializerContext is not an optimisation; it's the correctness contract under trim. [JsonConstructor] selects among multiple constructors — it does NOT preserve constructors the trimmer has already deleted. [DynamicallyAccessedMembers] can preserve members but you have to annotate at the call site, and GetFromJsonAsync<T> is external library code you cannot modify. Source-gen sidesteps this entire class of problem.

The alternative I rejected

There is a tempting middle ground: keep the reflection-based deserialization, mark each target class with [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)], and rely on the trimmer to preserve everything. This works in isolated cases but:

Source-gen scales better: one file per assembly, one [JsonSerializable] per root type, generator walks the graph. It's also the pattern AOT compilation requires — if you ever set RunAOTCompilation=true, your JSON work is already done.

Verifying reflection is even on

If you want to check whether your published bundle has JSON reflection enabled at all, read the published runtimeconfig.json:

"System.Text.Json.JsonSerializer.IsReflectionEnabledByDefault": true

Under TrimMode=partial this stays true. Under full trimming, this switch flips to false and reflection-based JSON stops working entirely regardless of [DynamicallyAccessedMembers] annotations. Source generation is the only path that works consistently across every trim mode.

Summary


About the author

Chetan Sanghani is a Technical Product Manager and C# Corner MVP. He builds SmartTaxCalc.in — a free browser-based Indian income tax calculator suite. All 38 calculators run client-side on Blazor WebAssembly, CA-reviewed by ICAI 644575, no signup, no backend. Every production incident like the one described in this article makes the site a little more resilient. Reach out via smarttaxcalc.in/about/ or LinkedIn.