Last Sunday I split a Blazor WebAssembly app into a shared class library and a Web project. dotnet build: clean. dotnet publish: clean. Cloudflare Pages deployed. All my local smoke tests passed.
Four days later a user emailed: "The Calculate button doesn't work." Every calculator on the site was throwing this in the browser console:
Error: DeserializeNoConstructor, JsonConstructorAttribute,
TaxPlanner.Core.Models.TaxRulesConfiguration
Path: $ | LineNumber: 0 | BytePositionInLine: 1
This post is what I learned. If you have <PublishTrimmed>true</PublishTrimmed> in a Blazor WASM app and you use reflection-based System.Text.Json — HttpClient.GetFromJsonAsync<T>, JsonSerializer.Deserialize<T>, or [JsonSerializable] NOT declared — this could hit you too.
The setup
Blazor WASM app, .NET 10. Two projects:
-
TaxPlanner.Web— Blazor pages, services,<PublishTrimmed>true</PublishTrimmed>+<TrimMode>partial</TrimMode>(default for optimized WASM builds). -
TaxPlanner.Core— data models. The refactor I shipped extracted the record types into this new library.
The Core project's csproj had this one line I didn't think twice about:
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<IsTrimmable>true</IsTrimmable>
</PropertyGroup>
Every guide on optimizing Blazor WASM output size tells you to mark your app assemblies as trimmable. It sounds like the disciplined choice. The Core assembly is small — models + a couple of helpers — and shrinking the payload matters when you're serving to phones.
The data models are all like this:
public sealed record TaxRulesConfiguration
{
[JsonPropertyName("version")]
public string Version { get; init; } = "";
[JsonPropertyName("financialYears")]
public IReadOnlyList<FinancialYear> FinancialYears { get; init; } = [];
}
Loaded once at startup:
_rulesConfig = await _httpClient.GetFromJsonAsync<TaxRulesConfiguration>(
"data/tax-rules.json",
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
Standard stuff. Works fine in development. Passes every build.
What the trimmer actually did
TrimMode=partial means: only aggressively trim assemblies marked IsTrimmable=true. TaxPlanner.Core was the only such app assembly.
The trimmer's job is to remove unreachable IL. It does a static reachability analysis: it walks from every entry point (Program.Main, exposed public APIs, etc.), traces which types and members are called, and deletes everything not reached.
Here's what it saw: TaxRulesConfiguration is a sealed record with only { get; init; } properties. The compiler-generated parameterless constructor was public. But nothing in the codebase calls new TaxRulesConfiguration(). The only "caller" is HttpClient.GetFromJsonAsync<T>, which uses reflection at runtime to look up and invoke that constructor.
Reflection is invisible to the trimmer. From its perspective, the constructor was unreachable. It got deleted.
System.Text.Json's reflection path then tried to construct the object at runtime, found no public parameterless constructor, no [JsonConstructor] attribute, no custom converter, and threw:
DeserializeNoConstructor, JsonConstructorAttribute,
TaxPlanner.Core.Models.TaxRulesConfiguration
dotnet build? Green. dotnet publish? Green. The trimmer doesn't warn on this class of stripping because from its perspective it correctly identified unreachable code. The bug only exists at runtime, only in the browser, only when the deserialization path fires.
Fix attempt #1: turn trim off for the assembly
Obvious first move:
<IsTrimmable>false</IsTrimmable>
I pushed it. Cloudflare rebuilt. I checked bundle size — Core.wasm went from 12 KB to 49 KB, matching exactly the type metadata + constructor IL the trimmer had been eating. That's a great debugging signal on its own: a ~4× jump means "trim was doing real work here"; near-identical size means "trim was doing nothing and might have been silently breaking reflection targets."
Restarted. Loaded the site. Clicked Calculate.
Same error.
This is where I lost an hour. My mental model said: opting the assembly out of trim should restore its type metadata, which should be enough for System.Text.Json's reflection to find the constructor. It wasn't.
What actually happened at fix #1
I still don't have a definitive breakdown of exactly which System.Text.Json code path fails under PublishTrimmed=true when the target type's assembly is preserved. What I can tell you empirically: it fails. IsTrimmable=false on the target assembly is insufficient. Something about PublishTrimmed=true at the app level changes how the JSON reflection path resolves constructors, regardless of what the target's IsTrimmable says.
If someone from the runtime team reads this and knows the specific mechanism, I'd love to update this post.
The pragmatic lesson: don't rely on IsTrimmable=false as your defense.
Fix attempt #2: source-generation (the real answer)
Microsoft's official recommendation for trim-safe and AOT-safe JSON is System.Text.Json source generation. Instead of runtime reflection walking your types, the compiler emits the deserialization code at build time. Zero reflection needed. The trimmer sees the generated code as regular reachable IL.
Setup is two steps.
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 is what triggers codegen. You list every root type you want serialized/deserialized. The generator walks the object graph — TaxRulesConfiguration contains a List<FinancialYear>, each FinancialYear contains a TaxRegime and a Deductions, and so on — and emits deserializer code for every nested type. No need to enumerate them explicitly.
Step 2: change the call site from the reflection overload 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-based one.
Pushed. Cloudflare rebuilt. 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.
Calculate button worked. Every calculator worked. Four days of broken production ended.
Diagnostic patterns worth internalizing
Three signals I now watch for:
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. Same-size-before-and-after means trim was finding nothing trimmable there — usually safe to leave IsTrimmable=true.
2. dotnet build and dotnet publish both green does not mean runtime-correct under trim. The trimmer emits no warning for stripping a reflection-only constructor because it's doing its job. Trim regressions surface only in the browser, at the moment the deserialization fires. If you change anything trim-related — assembly split, IsTrimmable flip, DI graph reshaping — you must publish and actually navigate to the affected page in a real browser before believing the change is safe.
3. If the type is reflection-consumed, source-gen or nothing. [JsonSerializable] + JsonSerializerContext is not an optimization; it's the correctness contract under trim. Attributes like [JsonConstructor] are for choosing a constructor when there are multiple candidates — they don't preserve constructors that the trimmer already deleted. [DynamicallyAccessedMembers] in principle can preserve members but you have to annotate at the call site, and GetFromJsonAsync<T> is defined in an external library so you can't add annotations there. Source-gen sidesteps this entire class of problem.
The soft-fail alternative I considered and rejected
There's a tempting middle ground: keep the reflection-based deserialization, mark the target class with [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)], and hope the trimmer preserves everything. This can work in isolated cases. But:
- It requires modifying every reflection-consumed type in your assembly (missing one is a silent runtime break)
- It's easy to forget when you add a new type (there's no lint that catches it)
- It preserves more than the deserializer actually needs (partially defeats the trim wins you wanted)
Source-gen scales better: one file per assembly, one [JsonSerializable] per root type, generator walks the graph and figures out the rest. It also happens to be the pattern that AOT compilation requires, so if you ever move to AOT (RunAOTCompilation=true), your work is already done.
The 4-day window, in retrospect
I've been thinking about why this survived so long unnoticed. The commit that introduced the bug was one of 32 shipped that Sunday — an SEO-focused audit day. Every calculator on the site was silently broken from that commit forward. The site's own health dashboard (GA4 exceptions on the "Reviewer" page) was elevated but I had a different working theory for that spike. The failure only showed up when a user opened the browser DevTools and clicked Calculate — which happened four days later when someone reported it.
Two things would have caught it in minutes instead of days:
- Publish locally and click through the app after any assembly-boundary or trim change. I did the first (publish worked, bundle size was fine). I didn't do the second. Every "small architecture" change in a WASM app now gets a real-browser smoke test before I consider it done.
- An end-to-end test that clicks Calculate on the homepage. I don't have Playwright/Selenium set up yet on this project. The 4-day incident convinced me it's worth the ~2 hours of setup.
If your Blazor WASM stack is anything like the one in this post, and you've been leaving IsTrimmable=true on your app assemblies because "size wins are size wins," here's your reminder: check that the trimmer isn't quietly deleting things System.Text.Json's reflection needs.
I build client-side Indian tax calculators at smarttaxcalc.in — all Blazor WASM, no backend, CA-reviewed. Every incident like this one makes the site a little more resilient.










