bit Bswup

Preparing the app for offline use, please wait...

0%

Recipes

Task-shaped answers to the things teams actually hit in production: hosting headers, sub-path deployments, build-stamped cache versions, custom update banners, and keeping your API out of the service worker.

Deployment checklist

Bswup is only as reliable as the headers your host sends. Three rules cover almost every problem:

FileCache-ControlWhy
service-worker.js
_content/Bit.Bswup/bit-bswup.sw.js
no-cache These are how a client discovers there is an update. A cached worker script means clients never see new versions. Bswup already registers with updateViaCache: 'none', but support is uneven on older Safari/iOS and intermediary proxies are not bound by it.
The host document (index.html / the shell) no-cache It carries the script tags and the fingerprinted asset references. Once the worker is installed it is served from cache anyway, so a short TTL costs nothing.
Everything else (_framework/*, assets) long max-age Content-addressed by hash and verified by the worker, so a stale HTTP cache is never a correctness problem - only a speed win.
csharp
app.UseStaticFiles(new StaticFileOptions
{
    OnPrepareResponse = ctx =>
    {
        var path = ctx.Context.Request.Path.Value ?? string.Empty;
        var headers = ctx.Context.Response.GetTypedHeaders();

        // The worker scripts and any statically served shell must always revalidate.
        // (A Blazor Web App's shell is rendered by MapRazorComponents, not by this
        // middleware - set its headers there instead.)
        if (path.EndsWith("service-worker.js", StringComparison.OrdinalIgnoreCase) ||
            path.EndsWith("bit-bswup.sw.js", StringComparison.OrdinalIgnoreCase) ||
            path.EndsWith(".html", StringComparison.OrdinalIgnoreCase))
        {
            headers.CacheControl = new() { NoCache = true };
            return;
        }

        // Everything else is hash-versioned - cache it hard.
        if (env.IsDevelopment() is false)
        {
            headers.CacheControl = new() { MaxAge = TimeSpan.FromDays(7), Public = true };
        }
    }
});
Also verify these three

HTTPS. Service workers only register over HTTPS (or localhost). On plain HTTP nothing installs and the app silently runs with no offline support.

Both worker files. wwwroot/service-worker.js is used during development; wwwroot/service-worker.published.js is what a published build actually ships, via the ServiceWorker item's PublishedContent mapping. Editing only the first one is the single most common "it works locally" bug.

The assets manifest. <ServiceWorkerAssetsManifest> must be set in the WebAssembly project, or service-worker-assets.js is never generated and the worker has nothing to cache.

Host the app on a sub-path

An app served from https://host/myapp/ needs its scope narrowed - a worker can only control URLs beneath its own folder:

html
<script src="_content/Bit.Bswup/bit-bswup.js"
        scope="/myapp/"
        sw="service-worker.js"></script>

Everything else follows automatically: assetsUrl resolves relative to the worker script's own location (which is where .NET publishes the manifest), and the cache bucket is namespaced by the scope, so a second app at /otherapp/ keeps a fully independent cache. Only defaultUrl needs your attention: it must name an asset that really exists.

Hosting several apps on one origin
Give each app its own scope. Their buckets never collide, and BitBswup.forceRefresh() only ever clears the calling app's own registration and caches.

Stamp the cache version from your build

By default the cache bucket tracks Blazor's asset-manifest hash, which rotates whenever any asset hash changes - and only then. That is usually what you want. Take manual control when a meaningful change lives outside the manifest, or when noisy local rebuilds keep evicting the whole runtime:

javascript
// service-worker.published.js - replaced at publish time by your CI
self.cacheVersion = '#{BUILD_VERSION}#';   // e.g. '2026.08.12-a1b2c3d'

self.importScripts('_content/Bit.Bswup/bit-bswup.sw.js');

Feed it something that bumps once per publish - a commit SHA, a build number, your app's informational version. Pin it to a constant during development and dev rebuilds stop discarding the cached runtime on every save.

Replace the reload button with your own banner

The built-in progress UI is not all-or-nothing: the Handler parameter chains your function after the built-in handling, so you can keep the splash and take over just the update prompt. Hide the default button with CSS and show your own:

razor
<BswupProgress AutoReload="false" HideApp="true" Handler="appUpdateHandler" />

<div id="my-update-banner" hidden>
    A new version is ready.
    <button type="button" id="my-update-accept">Reload now</button>
</div>

<style>
    /* The component still renders it; we just drive our own UI instead. */
    #bit-bswup-reload { display: none !important; }
</style>
javascript
// Chained AFTER the built-in handling, so the splash and failure panel keep working.
window.appUpdateHandler = (type, data) => {
    if (type !== BswupMessage.updateReady && type !== BswupMessage.downloadFinished) return;
    if (data.firstInstall) return;   // a first install completes on its own - nothing to prompt

    const banner = document.getElementById('my-update-banner');
    banner.hidden = false;
    document.getElementById('my-update-accept').onclick = data.reload;
};
Why hide rather than omit
#bit-bswup-reload is always rendered by the component, even with custom ChildContent - it is the only way a finished update surfaces under the default AutoReload="false". Hiding it and driving your own element keeps that guarantee intact while the UI stays yours.

Keep your API out of the service worker

Bswup caches your application, not your data. API responses are not in the assets manifest, so they are already left alone by rule 5 of the request pipeline. Declaring them explicitly is still worth it - it short-circuits the routing earlier and documents the intent:

javascript
// Never enters the offline pipeline - straight to the network, every time.
self.serverHandledUrls = [/\/api\//, /\/signalr\//, /\/health$/];

// Real server-rendered pages: do not answer these with the SPA shell.
self.serverRenderedUrls = [/\/privacy$/, /\/payment\/callback/];

serverHandledUrls means "never enter the offline pipeline"; serverRenderedUrls means "this URL is a real server-rendered page, do not answer it with the SPA shell". Use the second one for anything the server renders itself - a privacy page, a payment callback, a health endpoint.

Cache assets from another origin

Fonts, an analytics script, a CDN library - anything outside service-worker-assets.js - are declared by hand:

javascript
self.externalAssets = [
    { "url": "/" },                                      // the host page itself (e.g. _Host.cshtml)
    "https://fonts.example.com/inter.woff2",             // bare string shorthand
    { "url": "/lib/chart.js", "hash": "sha256-..." },     // cache busting + integrity
    { "url": /_framework\/resource-collection\..*\.js/ }  // server-generated name
];

Cross-origin entries are fetched with CORS first; if the host sends no CORS headers, Bswup retries with no-cors and caches the resulting opaque response, which script and image tags consume normally. Two things to know: browsers pad opaque responses in quota accounting (Chromium reserves several megabytes each), and an opaque body cannot be integrity-verified, so the fallback is skipped for entries carrying a hash. Prefer CORS-enabled hosts where you control them.

Ship a fully offline app

A mode is a preset bundle of defaults that only fills settings you have not assigned yourself, so your explicit values always win:

javascript
self.mode = 'FullOffline';        // precache everything, serve from cache from the first load
self.errorTolerance = 'strict';   // and refuse to install a partial cache

self.importScripts('_content/Bit.Bswup/bit-bswup.sw.js');

Reach for strict tolerance alongside it when a partially populated cache is unacceptable: an install that cannot fetch every required asset is discarded whole, and the previous version keeps serving. The trade-off is real - one 404 on an optional asset then fails the entire install - so keep externalAssets tight when you choose it.

Every mode presets defaultUrl to '/'
That URL is not in service-worker-assets.js, so unless your shell really is index.html at the root you also need self.externalAssets = [{ "url": "/" }] - otherwise navigations have nothing to fall back to and the app will not work offline. Bswup warns about this at startup; see the troubleshooting entry.

Boot fast, cache only what is used

The opposite trade-off: isPassive lets the first paint happen immediately instead of waiting behind a full precache.

javascript
self.isPassive = true;

It means exactly "never download everything": nothing is precached during install, and nothing is topped up in the background afterwards either. Each asset is cached the first time the running app requests it, so the user is never held behind a progress bar and never pays for assets the app does not use. The cost is offline completeness - a page whose lazy-loaded assets were never requested while online will not work offline. If that matters more than the first paint, leave passive mode off and use mode = 'FullOffline'.

Ask for durable storage at the right moment

Cached assets live in best-effort storage by default: browsers reclaim it under disk pressure, and Safari deletes all storage for a site untouched for seven days - the user comes back offline to an app that no longer boots. Requesting persistence exempts the origin, but grant odds are engagement-based, so when you ask matters more than whether you ask:

javascript
// Ask right after the user has shown intent - a gesture is what moves the odds.
document.getElementById('enable-offline').onclick = async () => {
    const persisted = await BitBswup.persistStorage();
    status.textContent = persisted
        ? 'This app is now available offline and protected from eviction.'
        : 'Offline mode is on, but the browser may reclaim it under storage pressure.';
};

Prefer this to the persistStorage script attribute (which asks at startup, before the user has demonstrated any intent) whenever your app has a natural high-signal moment: after login, after a save, or from an explicit "make available offline" button.

Add a "check for updates" button

javascript
const button = document.getElementById('check-updates');

button.onclick = () => {
    button.disabled = true;
    BitBswup.checkForUpdate()
        .catch(err => console.warn('unexpected update check error:', err));
};

window.appUpdateHandler = (type) => {
    if (type === BswupMessage.updateNotFound) {
        button.disabled = false;
        status.textContent = 'You are on the latest version.';
    }
    if (type === BswupMessage.updateCheckFailed) {
        button.disabled = false;
        status.textContent = "Couldn't check right now - you're still on the current version.";
    }
};

A failed check does not reject - it flows through UPDATE_CHECK_FAILED so the built-in progress UI never mistakes an offline moment for a broken install. The catch is only for genuinely unexpected errors, such as calling before the registration is ready.

Run under a strict Content-Security-Policy

Nothing Bswup ships requires unsafe-inline or unsafe-eval of its own: the BswupProgress component emits no inline <script>, passing its configuration through data-bit-bswup-* attributes instead. That leaves only what Blazor WebAssembly itself needs:

text
Content-Security-Policy:
    default-src 'self';
    script-src 'self' 'wasm-unsafe-eval';
    style-src 'self';
    worker-src 'self';
    connect-src 'self';
The worker scripts are part of your trusted base
A service worker intercepts every request, and neither register() nor importScripts() supports Subresource Integrity - so a tampered service-worker.js or bit-bswup.sw.js is effectively persistent, fully-privileged XSS. This is not Bswup-specific (Workbox shares the limitation), but it does mean the origin serving those two files must be one you control.

Develop without fighting the cache

  • Use "Update on reload" in dev tools → Application → Service Workers. It activates a new worker on every reload, which removes the entire class of "why am I still seeing the old build".
  • Turn the logs up. log="verbose" on the script tag narrates the page-side handshake; self.enableDiagnostics = true narrates the worker. enableFetchDiagnostics adds every intercepted request - loud, but decisive when a request is going somewhere unexpected.
  • Pin cacheVersion to a constant locally so rebuilds stop rotating the bucket.
  • Reach for BitBswup.forceRefresh() when a client is genuinely wedged - it clears this app's caches, unregisters its worker, and reloads.