DEV Community

Chetan Sanghani
Chetan Sanghani

Posted on

How I got LCP under 2s on a 38-page Blazor WebAssembly site (Cloudflare Pages)

I run SmartTaxCalc.in — a free Indian tax calculator platform built as a Blazor WebAssembly SPA on Cloudflare Pages. 38 tools, 20+ blog articles, all client-side. Very static, very cheap to host, easy to iterate.

For the first several months, mobile Largest Contentful Paint (LCP) was 6-8 seconds. That's failing Core Web Vitals by a mile. Every Lighthouse run screamed at me. And every SEO agency told me the same three things:

"It's Blazor. It's slow. Rewrite in Next.js."

Yeah, no. I like Blazor. I've shipped .NET for a decade. I don't want to rewrite for the sake of a Lighthouse score.

So over one weekend I ran a proper performance pass. Six mechanical fixes, six commits, mostly one-liners. By the end of it, mobile LCP was under 2 seconds — well inside the "Good" threshold — on every measured page.

Here's exactly what worked. Real commit hashes, real numbers, and honest notes on what didn't help.

The setup — what "LCP on Blazor WASM" actually means

Blazor WebAssembly is a Single Page Application. The initial HTML is a small shell (index.html) with a <base href="/">, some CSS <link> tags, and a <script src="_framework/blazor.webassembly.js">. That JavaScript downloads the .NET runtime, your app DLLs, and boots the Blazor renderer, which then constructs the DOM.

LCP is measured on the largest content element rendered above the fold. On a fresh Blazor site, that element usually doesn't exist until Blazor finishes booting and renders your first component. So "LCP" and "time-to-Blazor-hydrated" tend to be the same thing.

That said — a well-architected Blazor site with static HTML mirrors (pre-rendered content) can have LCP fire on the static content BEFORE Blazor hydrates, using Blazor purely for interactivity. That's the model my site uses.

So the LCP battle here is really a "get the browser to render the largest text/image element as fast as possible" battle. Blazor still boots, but LCP doesn't wait for it.

Before numbers — Lighthouse mobile, throttled 4G, Moto G4 CPU

Page Mobile LCP (before)
Homepage ~6.2s
Tax calculator (deep interactive page) ~7.8s
Blog article (mostly static content) ~5.9s

The 6-8s range is what you get when you serve unminified CSS, ship a service worker that races the main thread, and don't preconnect to your CDN edges.

Fix 1: Delete the service worker (commit 910c606)

The counter-intuitive one.

I originally shipped a service worker in June 2026 to fix a different bug (stale HTML cache after a build-tool migration). The SW's activate handler would detect its own version, unregister itself, and clear all caches. It was a one-time cleanup for existing users who were pinned to the old broken cache.

Two weeks later, every recurring visitor had had that cleanup fire. New visitors didn't need a service worker at all — but I was still registering one on every page load.

// Before — every page did this
if ('serviceWorker' in navigator && location.hostname !== 'localhost') {
    navigator.serviceWorker.register('/service-worker.js?v=cleanup-20260613', {
        updateViaCache: 'none'
    }).catch(function () {});
}
Enter fullscreen mode Exit fullscreen mode

Removing that block saved:

  • One network request (/service-worker.js)
  • ~50-100ms of post-load main-thread work as the browser evaluates the SW script
  • One CPU spike that competed with Blazor's own boot

Impact: ~200ms LCP win.

I left the service-worker.js file in wwwroot for now — old bookmarks that had registered the SW will still fetch it and run the self-unregister logic. That's a slow-decay migration, safe to leave.

Fix 2: Removed a version-check IIFE that force-reloaded users (commit 910c606)

This one hurt when I found it.

Buried in index.html was a small self-invoking function that read a version field from /version.json, compared it to a value stored in localStorage, and if they mismatched, called location.reload(true) — a hard reload.

The intent was noble: force existing users to fetch new HTML after each deploy so they'd never see stale content. But the effect on a first-visit user was:

  1. Browser fetches index.html
  2. Browser starts parsing → hits the IIFE
  3. IIFE fetches /version.json
  4. IIFE reads localStorage, finds no version stored → sets oldVersion = null
  5. IIFE sees null !== '20260620j' → calls location.reload(true)
  6. Browser tears down the entire page and starts over. LCP timer resets.

The first-visit user was silently reloading their own page before LCP could fire. Which meant their LCP was measured on the SECOND load, but the timer count-up started from the FIRST navigation. Result: catastrophic LCP.

Deleted the IIFE entirely. LCP-first-visit dropped by an estimated 2 seconds.

Impact: ~2000ms LCP win on first visits.

Fix 3: Preconnect coverage on 7 pages that were missing (commit 910c606)

<link rel="preconnect"> and <link rel="dns-prefetch"> don't do anything visible, but on a slow mobile connection they save hundreds of milliseconds by opening DNS + TCP + TLS connections to third-party origins BEFORE the browser actually needs them.

I had preconnect hints on the homepage and some tools, but 7 static HTML mirrors were missing them entirely. Namely:

<link rel="preconnect" href="https://www.googletagmanager.com" crossorigin>
<link rel="preconnect" href="https://pagead2.googlesyndication.com" crossorigin>
<link rel="dns-prefetch" href="https://adservice.google.com">
<link rel="dns-prefetch" href="https://ep1.adtrafficquality.google">
Enter fullscreen mode Exit fullscreen mode

These are the origins my site talks to for analytics + AdSense (site-wide). Without preconnect, the browser has to do the DNS lookup + TCP handshake + TLS negotiation only when the first request to those origins fires — which is AFTER my HTML is fully parsed.

With preconnect, those handshakes happen IN PARALLEL with HTML parsing. On a 4G mobile connection, that's ~300-500ms saved.

Added the four preconnect/dns-prefetch lines to every static HTML mirror. Nothing fancy.

Impact: ~300ms LCP win on cold pageviews.

Fix 4: Cache-buster pattern for CSS/JS (commit a9255e8)

Not a direct LCP fix, but a prerequisite for all the others actually reaching users.

If your CSS is at /css/app.css and you update it, browsers and CDN edges will keep serving the old content — because /css/app.css is the same URL as yesterday. Cache poisoning.

Standard fix: append a version query string that changes on every deploy: /css/app.css?v=20260703a. When the version changes, the URL changes, and every cache treats it as a new resource.

Baked this into every HTML mirror:

<link rel="preload" as="style" href="/css/app.css?v=20260703a" fetchpriority="high" />
<link href="/css/app.css?v=20260703a" rel="stylesheet" fetchpriority="high" />
<link rel="preload" href="/_framework/blazor.webassembly.js?v=20260620j" as="script" />
Enter fullscreen mode Exit fullscreen mode

Also added fetchpriority="high" on the CSS <link> — a browser hint that tells Chrome to prioritize this request over async scripts.

Impact: without this, none of the other CSS/JS fixes would deploy reliably. LCP win: indirect but load-bearing.

Fix 5: Cloudflare Brotli edge compression

This one you don't code — you enable it in the Cloudflare dashboard. But it's the biggest single win of the six.

My app.css is 131KB raw. That's not small. GZIP compresses it to ~40KB on the wire (typical text ratio 3:1). Brotli — Cloudflare's default compression for modern browsers — takes it to ~24KB (ratio 5.5:1).

For a slow 4G mobile connection at 1 Mbps effective throughput:

  • 131KB raw = ~1050ms transfer time
  • 40KB gzip = ~320ms transfer time
  • 24KB brotli = ~192ms transfer time

That's a ~700ms LCP win on the render-blocking CSS alone. FREE. Just toggle Brotli in Cloudflare Speed → Optimization.

If you're on Cloudflare and Brotli isn't on, this is your fastest-possible perf win. Zero code changes.

Impact: ~700ms LCP win.

Fix 6: Blazor WASM bundle size — PublishTrimmed + linking + InvariantGlobalization

The .NET runtime + your app DLLs downloaded on first visit is normally 3-5MB of WASM. That's a lot for a mobile connection. Three project-file switches shrink it significantly:

<PropertyGroup>
  <PublishTrimmed>true</PublishTrimmed>
  <BlazorWebAssemblyEnableLinking>true</BlazorWebAssemblyEnableLinking>
  <InvariantGlobalization>true</InvariantGlobalization>
</PropertyGroup>
Enter fullscreen mode Exit fullscreen mode
  • PublishTrimmed — .NET's IL linker strips unused types. Removes everything from mscorlib you're not calling. Typically 50-70% size reduction.
  • BlazorWebAssemblyEnableLinking — same idea but Blazor-specific.
  • InvariantGlobalization — removes the ICU globalization data (~1.5MB). Only viable if you don't need locale-aware string formatting. For a tax calculator that runs in one language, safe.

Combined effect on my site: Blazor bundle dropped from ~5MB to ~1.8MB. That's a big deal on first-visit mobile.

Note: this doesn't affect LCP directly IF your static HTML already renders the content (which mine does). But it dramatically speeds up time-to-interactive, which affects INP (Interaction to Next Paint) — the other Core Web Vital.

Impact: 200-400ms INP win + faster hydration.

After numbers — Lighthouse mobile, same throttling profile

Page LCP (before) LCP (after) Delta
Homepage 6.2s 1.84s -4.36s
Currency converter 7.1s 1.21s -5.89s
Salary tax guide 5.9s 1.56s -4.34s
Tax calendar 5.5s 1.55s -3.95s
HRA calculator 7.4s 1.54s -5.86s

Every measured page is now under the 2.5s "Good" LCP threshold. Most are under 2s.

The homepage cut ~4.4 seconds from real user-facing render time with 6 commits of mechanical work. No framework rewrite. Blazor is still Blazor.

What did NOT help (I'm being honest)

Not everything I tried worked. Skip these:

  • Minifying CSS manually. Cloudflare's Brotli already compresses better than any hand-minifier. Diminishing returns.
  • Lazy-loading below-fold sections with content-visibility: auto. Improved paint but caused CLS (layout shifts) that hurt the CLS Core Web Vital more than the LCP improvement helped.
  • Removing Google Analytics 4. Yes, GA4 slows things down. No, removing it is not viable for a site that needs to measure user behavior. Async loading is enough — the perf cost is real but small (~50-100ms), and the observability win is huge.
  • Custom preloading of individual DLLs. Blazor's default boot flow is already tuned. My hand-crafted preloads made things worse.

Two takeaways for anyone doing this

1. Delete more than you add. My biggest wins were removing things — service workers, version-check IIFEs, unused globalization data. Every one of those was "added carefully to fix a specific bug last month" and had turned into dead weight. Audit your legacy carefully.

2. The CDN dashboard is code. Toggling Brotli in Cloudflare beat every code-level optimization I did. Don't skip the "settings you can change without deploying" — they're often the fastest win.

Result

Site: smarttaxcalc.in — free Indian income tax calculator, 38 tools, Blazor WASM on Cloudflare Pages.

Mobile Core Web Vitals: all Good.

Framework: unchanged. Still Blazor.

Total code changed: ~15 lines across 6 commits.

If you're on Blazor WASM and hitting the "just rewrite it" wall from external advice, try the six mechanical fixes above first. Most of them are one-line changes. Blazor is faster than it gets credit for — you just need to stop actively slowing it down.


About me: I'm Chetan Sanghani — I build SmartTaxCalc, a free CA-reviewed Indian tax calculator platform. I write about frontend performance, SEO for JavaScript apps, and building small-team B2C products on .NET. Previous article: Google's MathSolver JSON-LD validator is stricter than schema.org — I learned that the hard way over 4 rejections.

Top comments (1)

Collapse
 
chetansanghani profile image
Chetan Sanghani

One thing I deliberately left out (would love to hear if others have solved it): I still have that service-worker.js file sitting in wwwroot even though it's no longer registered. It exists purely so old bookmarks pinned to the previous SW can hit it once, run the self-unregister logic in activate, and clean themselves up.

The plan is to delete the file in ~90 days once the tail of returning visitors clears the migration. But it feels weirdly stateful for a supposedly-static site.

Curious — has anyone here run into this "graceful SW retirement" problem, and did you keep the file forever or hard-delete after some window? I'm leaning toward measuring service-worker.js request volume in Cloudflare Analytics and deleting once it drops under 10/day.

Also — did anyone else hit the version-check IIFE trap? That one was hilarious in retrospect but genuinely embarrassing to find.