
One of the advantages of choosing to build an application as a PWA (or Progressive Web Application) is that it can run offline. This is achieved through the use of a service worker.
A service worker acts as a proxy between the browser and the network. We can use a service worker to intercept any network requests and perform a different action based on whether or not the application is online, thus providing the user with a better offline experience.
Modern browsers have the tools and capabilities to help us deliver an effective offline experience. When the application is online, we can persist data to the device in several ways, including local storage, cache or IndexedDB. Once this data is stored on the device, it can be served should the user go offline.
In this tutorial, we will be building a Blazor WASM (or WebAssembly) PWA application. Our PWA application will have a service worker that will intercept GET and POST requests.
It is common practice to adopt a cache-first strategy when building offline PWAs. We will intercept a GET request and see whether the response for that request is already in the cache. If it is, we’ll serve the user with the cached response. If it is not in the cache, we’ll request the data over the network, and save the response to the cache for offline use later, before returning the response.
If a POST request is made whilst the application is offline, we’ll save the URL, authorization header and body of the request in IndexedDB. We will then periodically check to see if there are any requests saved in IndexedDB, and if the device is online, we’ll perform each request and remove the record from IndexedDB.
Note that the techniques used in this tutorial are inherent to offline PWAs and are not specific to Blazor. Also note that Blazor’s PWA template only supports offline when published, meaning that code has to be written in service-worker.published.js as opposed to service-worker.js. This also means that any offline implementation can only be tested if the application is published.
Jump to section:
Handling an Offline GET Request using Cache
Handling an Offline POST Request using IndexedDB
Handling an Offline GET Request using Cache
When we first create a Blazor WASM PWA template, it already comes with a service worker. Let’s begin by inspecting what the fetch event listener inside service-worker.published.js does:
async function onFetch(event) { let cachedResponse = null; if (event.request.method === 'GET') { // For all 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'; const request = shouldServeIndexHtml ? 'index.html' : event.request; const cache = await caches.open(cacheName); cachedResponse = await cache.match(request); } return cachedResponse || fetch(event.request); }
As is common with PWAs, the Blazor PWA template already adopts a cache-first strategy. What’s missing from this template however is persisting data in the cache. Without caching a response, there won’t be anything in the cache in the first place to serve the user.
Let’s update the fetch listener to persist a request in the cache:
async function onFetch(event) { if (event.request.method === 'GET') { const cache = await caches.open(cacheName); let cachedResponse = null; // For all 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'; const request = shouldServeIndexHtml ? 'index.html' : event.request; cachedResponse = await cache.match(request); if (cachedResponse === undefined) { // If request is not in cache const fetchResponse = await fetch(event.request.url); // Fetch from network... cache.put(event.request.url, fetchResponse.clone()); // ...then put in cache to be served next time return fetchResponse; } return cachedResponse; } }
Now if the response isn’t in the cache, not only do we perform the request over the network, but we persist the response in the cache. If the same request is made again, its response can be retrieved from the cache.
We can use the weather forecast page to see if our cache is working. We’ll update the endpoint to have a slight delay before returning the data so that we can better see what is happening over the network. If the request takes a few seconds, we know it has come from the network:
[HttpGet] public async Task<ActionResult<IEnumerable<WeatherForecast>>> Get() { await Task.Delay(3000); return Enumerable.Range(1, 5).Select(index => new WeatherForecast { Date = DateOnly.FromDateTime(DateTime.Now.AddDays(index)), TemperatureC = Random.Shared.Next(-20, 55), Summary = Summaries[Random.Shared.Next(Summaries.Length)] }) .ToArray(); }
Let’s test our GET listener in the browser. First, publish the application:

Run the application:

Then go to the appropriate address:

With the network online, we can see that request takes a little over 3 seconds:

Now if we turn the network offline and perform the same request, the time is significantly less as the application fetches the data from the cache as opposed to requesting it over the network:

Handling an Offline POST Request using IndexedDB
Next, we’ll look at persisting the details of a POST request to the device if the application is offline, then sending the request over the network once the connection has been re-established.
Let’s first build a form that we can use to perform a POST request. Create the following Person, PersonController and PostData.razor files:
public class Person { [Required] public string? FirstName { get; set; } [Required] public string? LastName { get; set; } }
[ApiController] [Route("[controller]")] public class PersonController : ControllerBase { private readonly ILogger<PersonController> _logger; public PersonController(ILogger<PersonController> logger) { _logger = logger; } [HttpPost] public async Task<ActionResult<Person>> Post(Person person) { // Do something with the person record! await Task.Delay(2000); return Ok(person); } }
@page "/postdata" @using BlazorWASMOfflinePWA.Shared @inject HttpClient Http <PageTitle>Post data</PageTitle> <h1>Post data</h1> <EditForm Model="person" OnValidSubmit="HandleValidSubmit"> <DataAnnotationsValidator /> <ValidationSummary /> <div class="form-group mb-3"> <text>First Name</text> <InputText class="form-control w-auto" @bind-Value="person.FirstName" /> </div> <div class="form-group mb-3"> <text>Last Name</text> <InputText class="form-control w-auto" @bind-Value="person.LastName" /> </div> <button type="submit" class="btn btn-primary"> Submit </button> </EditForm> @code { private Person person = new(); private async Task HandleValidSubmit() { var response = await Http.PostAsJsonAsync<Person>("person", person); } }
Next, we’ll update the fetch event listener in our service worker to intercept POST requests:
async function onFetch(event) { if (event.request.method === 'GET') { // ...code from previous section } else if (event.request.method === 'POST') { var reqUrl = event.request.url; var authHeader = event.request.headers.get('Authorization'); return Promise.resolve(event.request.text()).then((payload) => { // if application is online, send request over network if (navigator.onLine) { return fetch(reqUrl, { method: 'POST', headers: { 'Accept': 'application/json', 'Content-Type': 'application/json', 'Authorization': authHeader }, body: payload }); } else { // if offline, save request details to IndexedDB to be sent later saveIntoIndexedDB(reqUrl, authHeader, payload); // return dummy response so application can continue execution const myOptions = { status: 200, statusText: 'Fabulous' }; return new Response(payload, myOptions); } }); } }
Once we resolve the body of our request, we see if the application is online. If it is, we perform the request over the network. If the application isn’t online, we save the request to IndexedDB and return a “dummy” response. This is so the application thinks the request has been successful and can continue in its execution.
Next, we’ll build the method that saves our request to IndexedDB. We first need to add a couple of variables:
const indexedDBName = 'WASMOfflinePWA'; const objectStoreName = 'OfflinePostRequests';
Add the following saveIntoIndexedDB method:
function saveIntoIndexedDB(url, authHeader, payload) { const DBOpenRequest = indexedDB.open(indexedDBName); DBOpenRequest.onsuccess = (event) => { // create request object const postRequest = [ { url: url, authHeader: authHeader, payload: payload } ]; db = event.target.result; const transaction = db.transaction([objectStoreName], 'readwrite'); const objectStore = transaction.objectStore(objectStoreName); const objectStoreRequest = objectStore.add(postRequest[0]); objectStoreRequest.onsuccess = (event) => { console.log("Request saved to IndexedDB"); } } }
We are now storing POST requests that were made whilst the application is offline to IndexedDB. We now need a way of performing these requests when the application comes back online.
Add the following checkNetworkState and sendOfflinePostRequestsToServer methods:
function checkNetworkState() { setInterval(function () { if (navigator.onLine) { sendOfflinePostRequestsToServer() } }, 3000); } async function sendOfflinePostRequestsToServer() { const DBOpenRequest = indexedDB.open(indexedDBName); // create the object store if doesn't exist DBOpenRequest.onupgradeneeded = (event) => { db = event.target.result; if (!db.objectStoreNames.contains(objectStoreName)) { objectStore = db.createObjectStore(objectStoreName, { keyPath: "id", autoIncrement: true }); } } DBOpenRequest.onsuccess = (event) => { db = event.target.result; const transaction = db.transaction([objectStoreName]); const objectStore = transaction.objectStore(objectStoreName); var allRecords = objectStore.getAll(); // get all records let currentRecord = null; allRecords.onsuccess = function () { if (allRecords.result && allRecords.result.length > 0) { for (var i = 0; i < allRecords.result.length; i++) { currentRecord = allRecords.result[i]; // perform request over network fetch(currentRecord.url, { method: 'POST', headers: { 'Accept': 'application/json', 'Content-Type': 'application/json', 'Authorization': currentRecord.authHeader }, body: currentRecord.payload }).then((response) => { if (response.ok) { const transaction = db.transaction([objectStoreName], 'readwrite'); const objectStore = transaction.objectStore(objectStoreName); // remove details from IndexedDB objectStore.delete(currentRecord.id); } else { console.log('An error occured whilst trying to send a POST request from IndexedDB.') } }); } } } } }
checkNetworkState fires sendOfflinePostRequestsToServer periodically. We have an onupgradeneeded event to create the IndexedDB and the object store should they not exist.
When IndexedDB is opened, we loop through all of the records in the appropriate object store. If there are records, we perform each request on the network and remove it from IndexedDB so it does not get performed twice.
Now all that is left to do is to fire our checkNetworkState method. Add the call to the method to the onInstall listener:
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)); checkNetworkState(); }
Let’s now see our POST interceptor in action:

With the application online, the POST request is performed over the network.
When the application is offline, the time taken for the request is much less as it hasn’t been performed over the network:

Instead, we see the details of the request in IndexedDB:

Now we just flick the application back online, and wait for the request to be performed:

The details of the request have also been removed from IndexedDB now that the request was successfully performed:

Conclusion
That concludes this tutorial on handling and performing GET and POST requests in a PWA whilst in offline mode using a service worker. We implemented a fetch event listener that caches the response of a GET request, so if that request is made again, the cached version of the response can be used. We also persisted details of a POST request to IndexedDB when the application is offline. We periodically checked our object store to see if there were any records in our IndexedDB, and if there were, performed these requests over the network if there was a network connection.