This is one of the topics I wanted to blog about a while ago already. In spring 2024, I implemented a feature request from a customer to make an existing Blazor Progressive Web App (PWA) offline-capable. The application I’m referring to consists of a ASP .NET Web API and a Blazor Web Assembly (Blazor WASM) client. Both are currently still running on .NET 6 and therefore Microsoft.AspNetCore.Components.WebAssembly is on version 6.0.26. The app offers functionalities around dynamic forms – their creation as well as the filling of such depending on the role of the user. Offline capability is required because users do not always have an Internet connection when filling out and submitting the forms.
The offline capability consists of two parts. On the one hand, loading the available forms (depending on the tenant affiliation of the user) and on the other hand, filling out and submitting the completed forms.
Let’s have a closer look into how these two parts can be solved.
Submission of completed forms
Disclaimer: this part was already implemented by my team and me before 2024.
If there is no internet connection (device is offline), completed forms cannot be sent to the API. To avoid connection errors in this case, the network status can be checked before sending the data to the API. If the network status is offline, the data of the filled out form can be stored in the local storage of the browser and as soon as the network status changes to online, the stored data is sent to the API.
The implementation of this approach could look as follows.
Let’s assume there is an icon in the header of the client app that reflects the network status – wrapped in a component called NetworkStatus.
NetworkStatus.razor
@using ArbitraryApp.Client.Services
@using ArbitraryApp.Client.Services.Interfaces
@inject IJSRuntime js
@inject IOfflineService offlineService;
@if (online)
{
@* online icon here *@
}
else
{
@* offline icon here *@
}
@code {
private bool online = true;
private OnNetworkStatusChangedInterop Interop { get; set; }
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!firstRender)
{
return;
}
Interop = new OnNetworkStatusChangedInterop(js);
await Interop.SetupOnNetworkStatusChangedCallback(HandleOnNetworkStatusChanged);
}
private Task HandleOnNetworkStatusChanged(OnNetworkStatusChangedEventArgs args)
{
online = args.Online;
if (args.Online)
offlineService.SetOnline();
else
offlineService.SetOffline();
StateHasChanged();
return Task.CompletedTask;
}
}
On first rendering, a new instance of OnNetworkStatusChangedInterop is created and the callback / event handler (HandleOnNetworkStatusChanged) to be executed each time the network status changes is registered. Next, let’s have a look at the code of OnNetworkStatusChanged and OfflineService.
OnNetworkStatusChangedInterop.cs
using System;
using System.Threading.Tasks;
using Microsoft.JSInterop;
namespace ArbitraryApp.Client.Helpers
{
public class OnNetworkStatusChangedInterop : IDisposable
{
private readonly IJSRuntime jsRuntime;
private DotNetObjectReference<OnNetworkStatusChangedHelper> reference;
public OnNetworkStatusChangedInterop(IJSRuntime jsRuntime)
{
this.jsRuntime = jsRuntime;
}
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
public ValueTask<string> SetupOnNetworkStatusChangedCallback(
Func<OnNetworkStatusChangedEventArgs, Task> callback)
{
reference = DotNetObjectReference.Create(new OnNetworkStatusChangedHelper(callback));
return jsRuntime.InvokeAsync<string>("addOnOfflineEventListener", reference);
}
protected virtual void Dispose(bool disposing)
{
reference?.Dispose();
}
}
}
In method SetupOnNetworkStatusChangedCallback an instance of OnNetworkStatusChangedHelper which exposes a JSInvokable method is created. Because of the [JSInvokable] attribute, the method can be called from JavaScript. The OnNetworkStatusChangedHelper instance is then used to create a .NET object reference which gets passed as an argument to the invocation of the JavaScript (JS) function addOnOfflineEventListener.
OnNetworkStatusChangedHelper.cs
using System;
using System.Threading.Tasks;
using Microsoft.JSInterop;
namespace ArbitraryApp.Client.Helpers
{
public class OnNetworkStatusChangedHelper
{
private readonly Func<OnNetworkStatusChangedEventArgs, Task> callback;
public OnNetworkStatusChangedHelper(Func<OnNetworkStatusChangedEventArgs, Task> callback)
{
this.callback = callback;
}
[JSInvokable]
public Task OnNetworkStatusChanged(bool online)
{
return callback(new OnNetworkStatusChangedEventArgs { Online = online });
}
}
}
The JavaScript code for registering event listeners looks as follows.
wwwroot\networkstatus.js
function addOnOfflineEventListener(dotNetObjectRef) {
window.addEventListener("online",
(event) => {
dotNetObjectRef.invokeMethodAsync("OnNetworkStatusChanged", true);
});
window.addEventListener("offline",
(event) => {
dotNetObjectRef.invokeMethodAsync("OnNetworkStatusChanged", false);
});
}
We covered the interactions between the razor component and JavaScript. The offline service and its use are still missing. Let’s complete the puzzle.
OfflineService.cs
using System;
using ArbitraryApp.Client.Services.Interfaces;
namespace ArbitraryApp.Client.Services
{
public class OfflineService : IOfflineService
{
public event EventHandler<OfflineStatusChangedEventArgs> OfflineStatusChanged;
public bool IsOffline { get; set; }
public void SetOffline()
{
IsOffline = true;
OnOfflineStatusChanged();
}
public void SetOnline()
{
IsOffline = false;
OnOfflineStatusChanged();
}
/// <summary>
/// Invokes the registered event handler with changed offline status
/// </summary>
protected void OnOfflineStatusChanged()
{
OfflineStatusChanged?.Invoke(this, new OfflineStatusChangedEventArgs() { IsOffline = IsOffline });
}
}
}
No magic at all. But where is the OfflineService (singleton) used? First of all, the offline service is used to determine, if the data of the filled out form should be sent to the API or if the data is stored in the local storage of the browser.
if (offlineService.IsOffline)
{
await syncService.AddDataToLocalStorage(Data);
throw new NoConnectionException();
}
...
Furthermore, the offline service is used to upload data from local storage respectively send this data to the API.
public override async Task OnInitializedAsync()
{
offlineService.OfflineStatusChanged += HandleOnNetworkStatusChanged;
await syncService.AttemptUploadLocallyStoredData();
}
private async void HandleOnNetworkStatusChanged(object sender, OfflineStatusChangedEventArgs args)
{
if (!args.IsOffline)
{
await syncService.AttemptUploadLocallyStoredData();
}
}
Last but not least, the code of the sync service.
SyncService.cs
using System;
using System.Collections.Generic;
using System.Data;
using System.Linq;
using System.Threading.Tasks;
using ArbitraryApp.Client.Services.Interfaces;
using ArbitraryApp.Shared.Models;
using Blazored.LocalStorage;
namespace ArbitraryApp.Client.Services
{
public class SyncService : ISyncService
{
private readonly IFormsService formsService;
private readonly ILocalStorageService localStorageService;
private readonly IAuthHelperService authHelperService;
public SyncService(IFormsService formsService, ILocalStorageService localStorageService, IAuthHelperService authHelperService)
{
this.formsService = formsService;
this.localStorageService = localStorageService;
this.authHelperService = authHelperService;
}
public async Task AddDataToLocalStorage(ArbitraryModel model)
{
var modelsFromLocalStorage = await GetModelsFromLocalStorage();
var currentUserId = authHelperService.UserId;
if (currentUserId == null)
{
throw new NoNullAllowedException("The current user id is null");
}
var newModel = new LocalStorageArbitraryModel()
{
Model = model, SubmittedById = currentUserId, SubmittedAt = DateTime.Now
};
modelsFromLocalStorage.Add(newModel);
await localStorageService
.SetItemAsync("OfflineCreatedArbitraryModels", modelsFromLocalStorage)
.ConfigureAwait(false);
}
public async Task<int> AttemptUploadLocallyStoredData()
{
var modelsFromLocalStorage = await GetModelsFromLocalStorage();
if (modelsFromLocalStorage.Count == 0)
{
return 0;
}
var currentUserId = authHelperService.UserId;
if (currentUserId == null)
{
return 0;
}
var modelsToUpload = modelsFromLocalStorage.Where(m =>
m.SubmittedById == currentUserId).ToList();
var successfulUploads = new List<LocalStorageArbitraryModel>();
var uploadTasks = modelsToUpload.Select(async m =>
{
try
{
await formsService.Create(m.Model);
successfulUploads.Add(m);
}
catch
{
// TODO: handle failure (retry)
}
});
await Task.WhenAll(uploadTasks);
modelsFromLocalStorage.RemoveAll(
m => successfulUploads.Contains(m));
await localStorageService
.SetItemAsync("OfflineCreatedArbitraryModels", modelsFromLocalStorage)
.ConfigureAwait(false);
return successfulUploads.Count;
}
private async Task<List<LocalStorageArbitraryModel>> GetModelsFromLocalStorage()
{
return
await localStorageService.GetItemAsync<List<LocalStorageArbitraryModel>>("OfflineCreatedArbitraryModels") ??
new List<LocalStorageArbitraryModel>();
}
}
}
Loading the available forms
As already outlined in the beginning, loading the available forms in my case depends on the tenant affiliation of the user. As new forms are regularly added by users in role Administrator and because the use case allows simplification from a business perspective, the solution has the following limitation: Each form that is to be available offline must be requested at least once while the device is online.
The offline availability is ensured by the service worker which acts as a proxy server between the web application (client), the browser and the network.
The Blazor Progressive Web App (PWA) template creates two service worker files.
wwwroot/service-worker.jswwwroot/service-worker.published.js
The first one is used for local development and the second one is used, if the app is published. To test the service worker in local development environment, the content of service-worker.published.js can be copied to service-worker.js. Let’s have a look a the contents of the service worker files.
src\Client\wwwroot\service-worker.js
// This service worker file is used for local development only.
// For local development, you should generally always fetch from the network and not enable offline support.
// This is because caching would make development more difficult (changes would not be reflected on the first load after each change).
self.addEventListener('fetch', () => { });
// NOTE: if you anyway want to test the production service worker with offline support, just replace the content of this file
// with the content of service-worker.published.js
Next, the content of the service-worker.published.js file which makes the preloaded forms available even if the device the application runs on is offline.
src\Client\wwwroot\service-worker.published.js
Important: consider Caveats for offline PWAs
// Caution! Be sure you understand the caveats before publishing an application with
// offline support
// During compilation a service worker assets manifest is created which lists all the static resources that
// the app requires to work offline, such as .NET assemblies, JavaScript files, and CSS, including their
// content hashes.
self.importScripts('./service-worker-assets.js');
// Register/add event listeners for install, activate and fetch events
self.addEventListener('install', event => {
self.skipWaiting(); // always activate updated service worker immediately
event.waitUntil(onInstall(event));
});
self.addEventListener('activate', event => event.waitUntil(onActivate(event)));
self.addEventListener('fetch', event => event.respondWith(onFetch(event)));
const cacheNamePrefix = 'offline-cache-';
const cacheName = `${cacheNamePrefix}${self.assetsManifest.version}`;
const offlineAssetsInclude = [ /\.dll$/, /\.pdb$/, /\.wasm/, /\.html/, /\.js$/, /\.json$/, /\.css$/, /\.woff?2$/, /\.png$/, /\.jpe?g$/, /\.gif$/, /\.ico$/, /\.blat$/, /\.dat$/ ];
const offlineAssetsExclude = [ /^service-worker\.js$/ ];
async function onInstall(event) {
console.info('Service worker: Install ...');
// Fetch and cache all matching items from the assets manifest
const assetsRequests = self.assetsManifest.assets
.filter(asset => offlineAssetsInclude.some(pattern => pattern.test(asset.url)))
.filter(asset => !offlineAssetsExclude.some(pattern => pattern.test(asset.url)))
.map(asset => new Request(asset.url, { integrity: asset.hash, cache: 'no-cache' }));
await caches.open(cacheName).then(cache => {
cache.addAll(assetsRequests);
});
console.info('Service worker: Install COMPLETED');
}
async function onActivate(event) {
console.info('Service worker: Activate ...');
// Delete unused caches
const cacheKeys = await caches.keys();
await Promise.all(cacheKeys
.filter(key => key.startsWith(cacheNamePrefix) && key !== cacheName)
.map(key => caches.delete(key)));
console.info('Service worker: Activate COMPLETED');
}
async function onFetch(event) {
// Network first strategy for API GET requests
// see https://borstch.com/blog/caching-strategies-in-pwa-cache-first-network-first-stale-while-revalidate-etc for more details on caching strategies
// see https://gist.github.com/JMPerez/8ca8d5ffcc0cc45a8b4e1c279efd8a94 for more details on API response caching
if (event.request.method === 'GET' && event.request.url.includes('/api')) {
const cache = await caches.open(cacheName);
return fetch(event.request, { credentials: 'include' }).then(response => {
const responseClone = response.clone();
// Cache the API GET response for offline use
cache.put(event.request, responseClone);
return response;
}).catch(() => {
let logMessage = `Service worker: GET ${event.request.url} from API failed, trying to serve response from cache ...`;
console.info(logMessage);
// If the network request fails, try to serve a response from the cache
return cache.match(event.request);
});
}
// Cache first strategy for navigation requests
let cachedResponse = null;
if (event.request.method === 'GET') {
// For navigation requests, try to serve index.html from cache
// If you need some URLs to be server-rendered, edit the following check to exclude those URLs
const shouldServeIndexHtml = event.request.mode === 'navigate'
&& !event.request.url.includes('/signin-oidc')
&& !event.request.url.includes('/signout-callback-oidc')
&& !event.request.url.includes('/HostAuthentication/')
&& !event.request.url.includes('/security.txt');
const request = shouldServeIndexHtml ? 'index.html' : event.request;
const cache = await caches.open(cacheName);
cachedResponse = await cache.match(request);
}
return cachedResponse || fetch(event.request, { credentials: 'include' });
}
The highlighted lines show the most important part of the service worker code regarding offline availability of the forms. For sure this approach can be driven even further so that the preload limitation can be eliminated (at least partially). However due to the fact that new forms are added regularly, loading them via an endpoint in the onInstall event handler does not solve the problem completely.


Leave a Reply