DEV Community

Chetan Sanghani
Chetan Sanghani

Posted on

One <link rel=preload> cut my Blazor WASM LCP by 500ms — the invisible file at the end of the critical chain

Two weeks ago I wrote about getting my Blazor WebAssembly site's LCP under 2 seconds. I thought I was done.

I wasn't.

Last week I ran a fresh PageSpeed Insights audit and saw this:

FCP:  0.3s   ✅ Great
LCP:  3.5s   ⚠️ Needs Improvement
TBT:  2,360ms 🔴 Poor
Enter fullscreen mode Exit fullscreen mode

FCP was 0.3 seconds. Something was painted in under a third of a second. So how was LCP 3.5 seconds? What was the browser waiting for?

The "LCP breakdown" panel in PageSpeed had the answer:

Subpart              Duration
Time to first byte   0 ms
Element render delay 5,500 ms
Enter fullscreen mode Exit fullscreen mode

5.5 seconds of "element render delay." Not download time. Not parse time. Not layout. Something was preventing the browser from painting the LCP element for five and a half seconds after it was ready.

Let me walk through the diagnosis and the three-line fix.

The "Network dependency tree" was the smoking gun

PageSpeed Insights has an under-appreciated panel: Network dependency tree. It shows the critical request chain — every resource the browser had to fetch before it could paint the LCP element.

Mine looked like this (edited for brevity):

Maximum critical path latency: 5,464 ms
Initial Navigation
  https://smarttaxcalc.in       169 ms   14.6 KiB
  /js/site-helpers.js          251 ms    8.7 KiB
  /_framework/dotnet.js         642 ms   17.5 KiB
  /_framework/dotnet.runtime… 740 ms   57.7 KiB
  /data/tax-rules.json       5,464 ms    5.6 KiB   ← ??
  /_framework/dotnet.native…    719 ms   37.9 KiB
  /_framework/dotnet.native…wasm  920 ms  1,155.7 KiB
  ... 40 more .wasm files ...
Enter fullscreen mode Exit fullscreen mode

Notice the /data/tax-rules.json line. 5.6 KB. 5,464 ms wall-clock.

That file is tiny. Downloading it should take a few hundred milliseconds max. Why was the wall-clock 5.5 seconds?

Because the browser was fetching it at the end of the chain, after all 43 .wasm assembly files had finished loading.

Why this happens on every Blazor WASM app

Blazor WebAssembly's boot sequence is roughly:

  1. Browser fetches index.html.
  2. Parses <script src="_framework/blazor.webassembly.js"> — starts downloading.
  3. blazor.webassembly.js downloads and starts running.
  4. It fetches blazor.boot.json (the manifest of runtime + your assemblies).
  5. It fetches all the .wasm files in parallel.
  6. It instantiates the runtime.
  7. It starts your Program.cs main.
  8. Your DI container resolves services, including HttpClient.
  9. Your app's initial render kicks off.
  10. Now any HttpClient.GetFromJsonAsync("/data/tax-rules.json") call runs.

Step 10 sits at the end of the wall-clock, waiting for everything above it to finish.

If your app renders content that depends on that JSON — in my case a tax slabs table that's a large above-fold element — LCP does not fire until step 10 completes.

FCP fires much earlier because the pre-rendered static HTML is painted at step 1. But LCP measures the LARGEST content, and Blazor re-paints the largest content only after step 10.

The fix — three lines in index.html

The browser has no idea /data/tax-rules.json is on the critical path until Blazor's runtime asks for it. But we can tell it, using a <link rel="preload"> hint at the very top of <head>.

Here's the diff:

<!-- Preload critical assets -->
<link rel="preload" href="_framework/blazor.webassembly.js?v=..." as="script" fetchpriority="high" />

<!-- NEW: Preload tax-rules.json — end of Blazor's critical request chain -->
<link rel="preload" href="data/tax-rules.json" as="fetch" fetchpriority="high" crossorigin />

<link rel="preconnect" href="https://www.googletagmanager.com" crossorigin />
Enter fullscreen mode Exit fullscreen mode

Three attributes matter:

  • as="fetch" — matches the HttpClient.GetFromJsonAsync() request that will happen later. Without this, the browser preloads it as the wrong type and skips using the cached copy.
  • fetchpriority="high" — hints that the browser should start this fetch immediately, not wait until the current parse yields.
  • crossorigin — required for as="fetch" preloads to match a CORS-enabled fetch. Even for same-origin, Blazor's HttpClient sends CORS-mode requests.

Before / after (real numbers, mobile Lighthouse, Slow 4G throttled)

Metric Before After Delta
FCP 0.4s 0.3s ▼25%
LCP 1985ms 1518ms 467ms
Element render delay 5,500ms 180ms ▼97%
Network critical path latency 5,464ms 106ms ▼98%
CLS 0 0 same

The 5,500ms element render delay collapsed to 180ms. LCP dropped 467ms on mobile.

Verified a day later via the PageSpeed Insights API in my nightly analytics fetch — the improvement stuck across measurements.

When this pattern applies

Anywhere in your Blazor WASM app you're doing something like:

// Program.cs or a startup component
protected override async Task OnInitializedAsync()
{
    var config = await Http.GetFromJsonAsync<TaxConfig>("data/tax-rules.json");
    // ...
}
Enter fullscreen mode Exit fullscreen mode

If that JSON blocks the first meaningful render (i.e., the LCP element depends on it), preload it in <head>. The file can be:

  • A JSON config file
  • A CSV/TSV data file
  • A pre-computed lookup table
  • Any other static resource under wwwroot/ that Blazor fetches during boot

If the file is under 20 KB it costs nothing to preload. If it's larger, weigh the bandwidth cost against the LCP win.

The trap I fell into first — assuming Blazor boot was fixed cost

I spent months blaming Blazor for slow LCP. Every diagnosis started with "Blazor WASM is inherently 3-4 seconds to boot."

That's true in the aggregate. But the aggregate hid the fact that one specific 5.6 KB file was sitting at the tail of the chain, single-handedly adding 3+ seconds to my perceived boot time.

If I'd read the "Network dependency tree" panel two months earlier, I would have shipped this fix in October.

Look at that panel first. Every time.

Anti-pattern — don't preload everything

You get one or two "high priority" preload slots that meaningfully move the needle. If you preload every static asset:

  • The browser can't prioritise the truly critical ones.
  • Bandwidth for blazor.boot.json (which is the actual entry point) gets contended.
  • Lighthouse will flag "Unused preload" audits.

Rule of thumb: preload only resources that would otherwise be at the end of the critical chain — meaning Blazor's runtime asks for them AFTER everything else is done.

Bonus lessons from the same optimization pass

While I was at it, I also tried:

  1. Enabling <RunAOTCompilation>true</RunAOTCompilation> — Cloudflare Pages CI build failed silently ("No deployment available"). Turns out the CI environment ships the base .NET SDK without the wasm-tools workload, and dotnet workload restore in a MSBuild target doesn't pull the emcc + binaryen native binaries. Reverted the same night. If you're on GitHub Actions with proper toolchain, this is worth trying.

  2. Deferring Google Analytics until first user interaction — moved 275ms of gtag.js main-thread evaluation out of Lighthouse's measurement window. TBT dropped ~500ms. Real users still get tracked as soon as they interact.

  3. Disabling Cloudflare Web Analytics beacon — 11 KB + 17ms main thread. Redundant if you already use GA4. Toggle it off in the Cloudflare dashboard under Analytics → Web Analytics.

Combined with the preload fix, my mobile Performance score went from 47 (desktop) → 73 (mobile) with mobile LCP firmly in the "Good" range.

Desktop LCP is still 3.5s though, and that's a longer story — Blazor SPAs replace pre-rendered content on wider viewports differently. That'll be article #6.


If you're on Blazor WebAssembly and your LCP feels stuck, open PageSpeed Insights, scroll to the Network dependency tree, and look at the last line. There's probably a 5 KB file sitting there costing you seconds.

The site: smarttaxcalc.in — a free Indian tax + finance calculator platform. 38 tools, CA-reviewed, all Blazor WebAssembly.

Top comments (0)