DEV Community

Chetan Sanghani
Chetan Sanghani

Posted on • Edited on

IsTrimmable=false is not enough: how one WASM setting silently broke my Blazor app for 4 days

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
Enter fullscreen mode Exit fullscreen mode

This post is what I learned. If you have <PublishTrimmed>true</PublishTrimmed> in a Blazor WASM app and you use reflection-based System.Text.JsonHttpClient.GetFromJsonAsync<T>, JsonSerializer.Deserialize<T>, or [JsonSerializable] NOT declared — this could hit you too.

Update after publishing: a reader pushed me to properly instrument the "fix #1 didn't work" mystery instead of hand-waving it. The corrected analysis is in What actually happened at fix #1 below. Short version: reflection was never disabled (TrimMode=partial keeps the JSON reflection switch on — confirmed via runtimeconfig.json), and the IsTrimmable=false build was probably a valid fix that a caching layer masked. Source generation is still the right answer. Kept the original narrative intact so the correction is honest.

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>
Enter fullscreen mode Exit fullscreen mode

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; } = [];
}
Enter fullscreen mode Exit fullscreen mode

Loaded once at startup:

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

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
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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 (updated after a reader's diagnostic)

The original version of this post threw up its hands here — "something about PublishTrimmed=true breaks reflection regardless of IsTrimmable." A sharp commenter pushed me to actually instrument it. Two findings changed the story.

First: reflection was never disabled. I'd half-assumed PublishTrimmed=true had flipped System.Text.Json's reflection feature switch off — which it does under full trimming. But this app uses TrimMode=partial, and I confirmed the switch's real value by reading the published TaxPlanner.Web.runtimeconfig.json:

"System.Text.Json.JsonSerializer.IsReflectionEnabledByDefault": true
Enter fullscreen mode Exit fullscreen mode

So the reflection deserializer was available the whole time. That rules out the feature switch and pins the root cause firmly on the trimmed constructor described above — exactly what the DeserializeNoConstructor error says on the tin. If you want to check your own build, that one line in runtimeconfig.json tells you whether reflection-based JSON is even on.

Second: the IsTrimmable=false build was probably correct — the browser just wasn't running it. Recall the bundle jumped 12 KB → 49 KB when I flipped the flag, i.e. the constructor metadata was restored in the published artifact. If the deployed bundle has the ctor and reflection is on, that build should work. The most likely reason it still threw the identical error: stale cached assets. Cloudflare Pages serves the fingerprinted _framework/*.wasm files with immutable cache headers, and the Blazor PWA service worker (if you ship it) keeps its own asset cache that only updates after every tab is closed. The click that "still failed" was very likely executing the previous trimmed bundle, not the fixed one.

I can't prove that 100% after the fact — the clean confirmation is to compare the served asset's integrity hash against the fresh build's at the time, which I didn't capture. But it fits every observable: green build, restored metadata, byte-identical runtime error, a CDN + service worker between me and the browser. The honest correction: IsTrimmable=false likely was a valid code-level fix, and a caching layer made it look useless.

The pragmatic lesson stands, just for a sharper reason: IsTrimmable=false is a fragile defense — it depends on the trimmer's reachability analysis and your cache invalidation both cooperating. Source generation depends on neither: the deserializer is ordinary reachable IL, so no reflection switch, no trim heuristic, and (once the new bundle is actually served) no ambiguity.

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 { }
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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:

  1. 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.
  2. 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.

Top comments (2)

Collapse
 
iqtechsolutions profile image
Ivan Rossouw

One diagnostic could close the unresolved part of Fix #1: PublishTrimmed=true normally sets JsonSerializerIsReflectionEnabledByDefault=false. I'd record JsonSerializer.IsReflectionEnabledByDefault in the published WASM build and retain the full exception type after switching IsTrimmable off.

Temporarily setting <JsonSerializerIsReflectionEnabledByDefault>true> would be a useful A/B test—not the production fix—to distinguish the application-level feature switch from a constructor removed during trimming. If reflection is already enabled, I'd inspect the published IL and service-worker/CDN cache rather than infer solely from bundle size.

Source generation remains the stronger production fix. I'd also run the Playwright regression against dotnet publish output, since a debug-host test does not exercise the same linker path. Did you capture those two values from the failed build?

Collapse
 
chetansanghani profile image
Chetan Sanghani

Ran your diagnostic — and you nudged me to a better answer than the post had. I pulled JsonSerializer.IsReflectionEnabledByDefault from the published runtimeconfig: it's true, not false. The project uses TrimMode=partial, which leaves the JSON reflection switch on — so the feature switch was never the cause. My post conflated that; correcting it.

That repoints the root cause at an actually-trimmed constructor: TaxPlanner.Core was IsTrimmable=true, and under partial trim that opted it in; TaxRulesConfiguration is only reached via reflection, so the linker dropped its ctor → DeserializeNoConstructor, exactly as you'd expect.

The part I can't cleanly close is why IsTrimmable=false (which should have preserved the ctor) didn't fix it — which is where your second point bites. Our deploy is Cloudflare Pages + a Blazor service worker, so a stale trimmed bundle from SW/CDN cache is the most likely reason the code fix looked ineffective until source-gen landed. I inferred from bundle size instead of inspecting published IL + cache headers; fair hit, and I'll do the IL/cache forensics properly.

Source-gen remains the right fix precisely because it's robust to all of this. And your Playwright-against-dotnet publish-output point is the real process lesson — my regression ran against the debug host and never touched the linker path. Thanks — genuinely sharpened my understanding of my own bug.