Are you shipping a Blazor WebAssembly app that dies the moment Wi‑Fi sneezes? Let’s fix that in one sitting: turn it into a Progressive Web App with offline support, install prompts, and painless updates.
What you’ll get by the end
- Installable Blazor WebAssembly app (desktop & mobile)
- Offline first‑run and subsequent reloads
- Caching for framework files, your DLLs, static assets, and optional API responses
- An update toast (“New version available – Reload”) with one tiny JS interop
- A clean, repeatable checklist for new projects and for retrofitting old ones
I’ll show two paths:
- Scaffolded (use the built‑in
--pwa
template), and- Retrofit (add PWA pieces to an existing Blazor WASM).
I’ve applied both in production; the retrofit path is ~20 minutes if you follow along.
Quick refresher: what makes a PWA?
A web app qualifies as a PWA when it has:
- Web App Manifest (name, icons, theme color, etc.)
- Service Worker (offline caching + install experience)
- HTTPS (required for service workers)
Blazor adds an extra twist: the runtime, your app DLLs, and static assets must be cached consistently to avoid weird half‑updated states.
Path A – Start with the template (fastest)
# New standalone Blazor WebAssembly with PWA bits
dotnet new blazorwasm --pwa -o MyPwaApp
# OR for a hosted solution (API + WASM + PWA)
dotnet new blazorwasm --hosted --pwa -o MyHostedPwa
Hit F5, open DevTools → Application → Service Workers, and you’ll see the worker registered. You already have a manifest, an icon set, and a published‑mode service worker that pre‑caches framework files and your assets.
Tip: The template uses
service-worker.published.js
at publish time. Duringdotnet run
you’ll see a development worker (service-worker.js
) that avoids aggressive caching to keep your dev loop sane.
If the template path fits your scenario, you’re 80% done. The rest of this post teaches you how to customize caching, show an install prompt, and display update notifications.
Path B – Retrofit an existing Blazor WASM to a PWA
Follow these steps in order. I’ll assume a standalone WASM project. I’ll note the hosted differences where they matter.
1) Add a manifest and link it
Create wwwroot/manifest.webmanifest
:
{
"name": "Contoso Tasks",
"short_name": "Tasks",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#0f172a",
"theme_color": "#2563eb",
"description": "Offline-first task tracking built with Blazor WebAssembly",
"icons": [
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" },
{ "src": "/icons/maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
]
}
In wwwroot/index.html
add in <head>
:
<link rel="manifest" href="manifest.webmanifest">
<meta name="theme-color" content="#2563eb">
<link rel="apple-touch-icon" href="/icons/icon-192.png">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
Icons: Generate at least 192×192 and 512×512 PNGs and a maskable icon. A single 512×512 maskable covers most devices nicely.
2) Add service worker files
Create wwwroot/service-worker.js
(development‑friendly; minimal caching to avoid stale dev assets):
// wwwroot/service-worker.js (DEV)
self.addEventListener('install', () => self.skipWaiting());
self.addEventListener('activate', event => event.waitUntil(self.clients.claim()));
Create wwwroot/service-worker.published.js
(used in Production/Publish):
// wwwroot/service-worker.published.js
// A versioned cache to evict old deployments
const CACHE_VERSION = self.crypto?.randomUUID?.() ?? Date.now().toString();
const CACHE_NAME = `blazor-pwa-${CACHE_VERSION}`;
// The assets manifest is generated at publish time by Blazor
self.importScripts('service-worker-assets.js');
// Choose which assets to pre-cache
const offlineAssets = self.assetsManifest.assets
.filter(a => !a.url.endsWith('.br') && !a.url.endsWith('.gz'))
.map(a => new URL(a.url, self.location).toString())
.concat([
'/', // the app shell
]);
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME)
.then(cache => cache.addAll(offlineAssets))
.then(() => self.skipWaiting())
);
});
self.addEventListener('activate', (event) => {
event.waitUntil((async () => {
// Remove old caches
const keys = await caches.keys();
await Promise.all(keys.filter(k => k.startsWith('blazor-pwa-') && k !== CACHE_NAME)
.map(k => caches.delete(k)));
await self.clients.claim();
// Tell clients an update is ready
const clients = await self.clients.matchAll({ includeUncontrolled: true });
clients.forEach(c => c.postMessage({ type: 'PWA_UPDATED' }));
})());
});
// Network strategy:
// 1) App shell & framework: cache-first
// 2) API GETs: stale-while-revalidate (opt-in by path)
// 3) Other requests: network-first with cache fallback
self.addEventListener('fetch', (event) => {
const request = event.request;
const url = new URL(request.url);
// Handle only same-origin
if (url.origin !== self.location.origin) return;
// App shell / static assets
if (request.method === 'GET' && (url.pathname === '/' || offlineAssets.includes(url.toString()))) {
event.respondWith(cacheFirst(request));
return;
}
// Example: cache API GETs under /api/todos
if (request.method === 'GET' && url.pathname.startsWith('/api/')) {
event.respondWith(staleWhileRevalidate(request));
return;
}
// Default network-first
event.respondWith(networkFirst(request));
});
async function cacheFirst(request) {
const cache = await caches.open(CACHE_NAME);
const cached = await cache.match(request);
return cached ?? fetch(request);
}
async function staleWhileRevalidate(request) {
const cache = await caches.open(CACHE_NAME);
const cached = await cache.match(request);
const network = fetch(request).then(resp => {
cache.put(request, resp.clone());
return resp;
}).catch(() => cached);
return cached ?? network;
}
async function networkFirst(request) {
const cache = await caches.open(CACHE_NAME);
try {
const response = await fetch(request);
cache.put(request, response.clone());
return response;
} catch {
const cached = await cache.match(request);
return cached ?? Response.error();
}
}
Why two files? In dev you don’t want your CSS & DLLs glued in cache while you’re iterating. In publish you absolutely want a versioned cache and deterministic updates.
3) Ensure the worker is registered
In wwwroot/index.html
near the end of <body>
:
<script>
if ('serviceWorker' in navigator) {
// During development:
navigator.serviceWorker.register('service-worker.js');
// On publish, Blazor rewrites this to service-worker.published.js automatically.
}
</script>
4) Wire an update toast via JS interop
service-worker.published.js
posts { type: 'PWA_UPDATED' }
when it activates a new version. Listen for that in JS and notify Blazor.
Create wwwroot/pwa-updates.js
:
window.pwaUpdates = {
onUpdateReady: (dotnetRef) => {
if (!('serviceWorker' in navigator)) return;
navigator.serviceWorker.addEventListener('message', (e) => {
if (e.data?.type === 'PWA_UPDATED') {
dotnetRef.invokeMethodAsync('ShowUpdateToast');
}
});
},
applyUpdate: async () => {
const reg = await navigator.serviceWorker.getRegistration();
if (reg && reg.waiting) {
reg.waiting.postMessage({ type: 'SKIP_WAITING' });
} else {
// Fallback: force reload
location.reload();
}
}
};
// Let the waiting worker take control immediately when told
self?.addEventListener?.('message', (event) => {
if (event.data?.type === 'SKIP_WAITING' && self.skipWaiting) {
self.skipWaiting();
}
});
In Pages/_Imports.razor
(or any component you prefer):
@using Microsoft.JSInterop
Create Shared/UpdateToast.razor
:
@inject IJSRuntime JS
@if (_show)
{
<div class="update-toast">
<span>New version available.</span>
<button @onclick="Apply">Reload</button>
</div>
}
@code {
private bool _show;
private DotNetObjectReference<UpdateToast>? _ref;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!firstRender) return;
_ref = DotNetObjectReference.Create(this);
await JS.InvokeVoidAsync("pwaUpdates.onUpdateReady", _ref);
}
[JSInvokable]
public Task ShowUpdateToast()
{
_show = true;
StateHasChanged();
return Task.CompletedTask;
}
private ValueTask Apply() => JS.InvokeVoidAsync("pwaUpdates.applyUpdate");
public void Dispose() => _ref?.Dispose();
}
Add simple styles in wwwroot/css/app.css
:
.update-toast {
position: fixed; inset: auto 1rem 1rem auto;
background: #0f172a; color: white; padding: .75rem 1rem;
border-radius: .75rem; box-shadow: 0 6px 24px rgba(0,0,0,.25);
}
.update-toast button { margin-left: .75rem; background:#2563eb; color:white; border:0; padding:.5rem .75rem; border-radius:.5rem; }
Drop <UpdateToast />
into MainLayout.razor
so it’s always available.
5) (Hosted) server configuration
If you’re using the hosted template, ensure the server project hosts the WASM correctly:
// Program.cs in Server project
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.UseHttpsRedirection();
app.UseBlazorFrameworkFiles();
app.UseStaticFiles();
app.MapFallbackToFile("index.html");
app.Run();
That fallback is crucial: it lets the app shell boot offline and routes are handled client‑side.
6) Publish & verify
dotnet publish -c Release
Check the publish folder contains service-worker-assets.js
(auto‑generated), service-worker.published.js
, your manifest, and icons. Deploy to an HTTPS host (Azure Static Web Apps, Azure App Service, Netlify, GitHub Pages, S3 + CloudFront, etc.).
Open DevTools → Application → Manifest and Service Workers. Try Offline checkbox and reload – your app should still render.
Add an install experience (optional but recommended)
Browsers show a native prompt automatically sometimes. You get better UX if you detect and propose installation yourself.
In wwwroot/install.js
:
let deferredPrompt;
window.pwaInstall = {
hook: (btnSelector) => {
const btn = document.querySelector(btnSelector);
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault();
deferredPrompt = e;
btn?.classList.remove('hidden');
});
},
prompt: async () => {
if (!deferredPrompt) return false;
deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice;
deferredPrompt = null;
return outcome === 'accepted';
}
};
In a component (e.g., InstallPrompt.razor
):
@inject IJSRuntime JS
<button class="hidden" id="install" @onclick="Install">Install app</button>
@code {
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
await JS.InvokeVoidAsync("pwaInstall.hook", "#install");
}
private async Task Install()
{
var accepted = await JS.InvokeAsync<bool>("pwaInstall.prompt");
// optionally log acceptance
}
}
This gives you a subtle “Install app” button that appears only when the browser says the app is installable.
Caching strategies that actually work for Blazor
Blazor apps bundle .dll, .wasm, and static assets. A safe strategy is:
- App shell & framework files – cache‑first (maximize offline reliability)
- API GETs – stale‑while‑revalidate (fast + eventually fresh)
- API mutations (POST/PUT/DELETE) – network‑only (avoid ghost writes)
In the earlier service-worker.published.js
we applied exactly that. If you need background sync for queued mutations, you can add it later via Background Sync (with caution on iOS support).
Versioning: We used a unique
CACHE_VERSION
per deployment and delete olderblazor-pwa-*
caches on activate. That avoids the classic “I shipped a fix but users still see the old app” problem.
Common pitfalls I’ve seen in real projects
- Forgetting HTTPS – service workers won’t register on plain HTTP (except localhost). Use a dev certificate locally and HTTPS in staging/prod.
- Non‑maskable icons – result: ugly crops on Android home screen. Always include a
"purpose": "maskable"
icon. - Aggressive dev caching – if you only use
service-worker.published.js
in dev, you’ll fight stale builds all day. Keep a minimalservice-worker.js
for development. - API base addresses – offline won’t help calls to a remote API. Cache only idempotent GETs you can tolerate being stale.
- Hosted fallback route missing – without
MapFallbackToFile("index.html")
your deep links 404, and offline boot fails. - Large initial payload – PWAs don’t excuse bloat. Trim by enabling AOT selectively, compressing images, and lazy‑loading assemblies.
Testing checklist (quick win)
- Lighthouse – PWA category ≥ 90
- Offline mode – toggle and reload: app shell renders
- Install prompt – appears when expected; app opens standalone
- Update flow – deploy a change, see “New version available” toast, click Reload
- API behavior offline – ensure the app fails gracefully or serves cached content
- iOS checks – Add to Home Screen, test launch splash and status bar style
Production hardening
- Compression: Serve Brotli/Gzip for
.dll
&.wasm
(App Service and most CDNs do this out of the box). - Immutable caching headers: Let your CDN cache static assets long‑term (
cache-control: public, max-age=31536000, immutable
). The service worker will still manage versioning. - Split caches per major release: Use a prefix like
blazor-pwa-v2-*
when you change loader behavior or manifest shape. - Telemetry: Track install events and update adoption (simple custom events via JS interop).
- Graceful offline UI: Show an “Offline” badge when
navigator.onLine === false
and ensure critical flows offer a retry.
Minimal ASCII architecture
+---------------------------+ HTTPS +----------------------+
| Browser (PWA container) | <--------------> | Static Host / CDN |
| - Service Worker | | (serves published) |
| - Cache Storage | +----------------------+
| - App Shell (Blazor) | Optional APIs over HTTPS
+---------------------------+ ^
^ |
| offline reads |
+---------------- Cache of GET /api/* ----+
FAQ: Blazor PWA in practice
Sometimes. Many orgs ship a PWA for desktop/mobile and only wrap it with a store package when needed (Microsoft Store, Play Store via TWA). Start with PWA; add store later if you need reach or native APIs.
iOS supports installable PWAs, offline cache, and manifest basics. Limitations: no beforeinstallprompt
(your custom button won’t show a native prompt; iOS uses “Add to Home Screen”), Background Sync and Push have gaps. Test on iOS Safari specifically.
Large .NET payloads still work, but first install time matters. Keep initial download lean: trim unnecessary assemblies, compress, lazy‑load.
Web Push works on most modern desktop/mobile browsers; it’s currently limited on iOS. You’ll also need a server component to send pushes. Start with update toasts first; push is a separate investment.
Not required. Blazor’s generated assets manifest + a small service worker (like the one above) is enough for most apps. If your caching logic grows complex, consider Workbox.
Because each release uses a new cache name, you can redeploy the previous build. Clients will adopt it on next refresh. For hard brakes, serve an emergency service-worker.published.js
that clears caches and reloads.
Conclusion: Your Blazor app, now resilient and installable
You just added a manifest, a pair of service workers, an update toast, and an optional install prompt. That’s the 20% of effort that brings 80% of the PWA benefits: instant loads, offline resilience, and a native‑like feel. Ship this to staging today, run Lighthouse, and tell me your score.
Which caching rule do you need most – cache‑first shell, stale‑while‑revalidate API, or something custom? Drop your case in the comments and I’ll suggest a precise strategy.