Skip to main content

Offline mode

Offline mode relies on the Blazor/Lagoon Service Worker and caching of resources required by the application.

Before publishing

Make sure you understand the cache impacts before publishing an application with offline support. An incorrect root URL or stale cache can prevent updates without clearing the browser cache.

Scope: what Lagoon does, what you must do​

Enabling the Service Worker makes the application runnable without a connection: Lagoon caches the application shell (HTML, WebAssembly, JavaScript, CSS, assets) and allows an authenticated user to remain logged in offline. However, this does not make your business features usable offline.

The table below defines responsibilities.

Supported by LagoonYour responsibility as developer
Caching the application (shell + assets) via the Service Worker, so it loads without network.Make data available offline (read from a local cache).
Maintaining the authenticated session offline (user claims are read back from the browser).Store inputs and changes locally that were made without network.
Reporting an HTTP call that fails due to no network, via the LgHttpFetchException exception.Detect this failure and switch to degraded / local operation.
—Synchronize local data with the backend when connection returns (replay pending actions).
Server calls do not work offline

An LgGridView bound to a controller, an LgEditForm that posts to an API, an HttpClient call… all fail as soon as the network is cut. If a page must work offline, you must explicitly design its local persistence and synchronization. None of this is automatic.

The detection mechanism is simple and deterministic: every HTTP call passing through the Lagoon pipeline raises LgHttpFetchException (namespace Lagoon.UI.Components) when the request fails due to no network (the Service Worker then returns a 418 code). Simply catch this exception to know that you are offline and react accordingly. The dedicated section below provides a complete example.

Reference implementation in the framework

The Notifications module (Lagoon.UI.Notifications) applies exactly this pattern: server call, capture of LgHttpFetchException, storage of notifications and a queue of "pending actions" in IndexedDB, then replay when connection returns. This is a good basis to study before implementing your own offline layer.

index.html and root path​

The root path of the application is defined by the base tag in wwwroot/index.html.

wwwroot/index.html
<base href="/" />

Modify this value before publishing if the application is deployed under a sub-path. A hash value is calculated during publishing to validate file integrity.

Enable the Service Worker​

Add or modify the ServiceWorkerEnabled property in the .csproj file of the Client project.

ValueEffect
falseThe Service Worker is not enabled.
trueThe Service Worker is enabled in all configurations. This value is discouraged.
Configuration nameThe Service Worker is enabled only for this configuration.

The recommended value is Release, to avoid cache issues in Debug.

MyProject.Client.csproj
<PropertyGroup>
<Version>1.0.0</Version>
<ServiceWorkerEnabled>Release</ServiceWorkerEnabled>
</PropertyGroup>

Behind the scenes​

When ServiceWorkerEnabled is enabled, Lagoon automatically adds the ServiceWorker item and generates service-worker.published.js. Do not add them manually to the project.

Generated project items
<ItemGroup>
<ServiceWorker Include="wwwroot\service-worker.js" PublishedContent="obj\...\service-worker.published.js" />
</ItemGroup>

Lagoon also sets the following build properties.

Generated build properties
<PropertyGroup>
<ServiceWorkerAssetsManifest>service-worker-assets.js</ServiceWorkerAssetsManifest>
</PropertyGroup>

Advanced offline asset configuration​

The server can add or remove resources from the cached list during offline asset generation. Override OnLoadOfflineAssets in the Main.cs of the Server project.

Main.cs
public class Main : LgAuthApplication<ApplicationDbContext, ApplicationUser>
{
protected override void OnLoadOfflineAssets(OfflineAssetManager assets)
{
// Lagoon already includes common extensions: .css, .dll, .html, .js, .json, .png, .svg, .wasm, etc.
assets.IncludeExtensions(".txt");

// Exclude a specific file from the offline cache.
assets.Exclude("data/Readme.txt");
}
}

Customize the Service Worker​

You can add a wwwroot/service-worker-setup.js file to inject application-specific code into the Service Worker.

wwwroot/service-worker-setup.js
self.addEventListener("message", event => {
// Add application-specific Service Worker processing here.
});

Design an offline-compatible feature​

This section illustrates, with a simple case, an offline-first approach: the application always works on a local browser cache (IndexedDB), never contacting the server during normal use. Synchronization with the backend only happens on explicit user request, via a dedicated button. The example manages a list of interventions: viewing, creating, and deleting work identically with or without network.

The advantage of this approach: the user experience is uniform (no network latency, no behavior difference based on connectivity) and offline management is no longer a special case but the normal operation.

The principle relies on three responsibilities, all your application's:

  1. Read and write only in the local IndexedDB cache; the server is never contacted during use.
  2. Track each local change (create, update, delete) in a queue of pending actions.
  3. Synchronize on explicit action: push pending actions to the server, then fetch the updated server state.

1. The local IndexedDB store (JavaScript)​

HttpClient and the Service Worker do not store your business data. You must persist it yourself. IndexedDB is the suitable choice for structured volumes; you access it via JsInterop. The script below exposes a small store (one data object store + a queue of pending actions) with purely local CRUD operations.

Place this file in Client/resources/main-js/. All scripts in this folder are automatically concatenated and minified into main.min.js, loaded at application startup. The store is therefore available globally (without ScriptIncludeAsync) and embedded by default in offline assets.

Client/resources/main-js/offline-store.js
window.MyApp = window.MyApp || {};

MyApp.OfflineStore = (function () {
const dataStore = "data";
const queueStore = "pendingActions";

function openDb(dbName) {
return new Promise((resolve, reject) => {
const request = window.indexedDB.open(dbName, 1);
request.onupgradeneeded = () => {
const db = request.result;
db.createObjectStore(dataStore, { keyPath: "id" });
db.createObjectStore(queueStore, { autoIncrement: true });
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}

return {
// Read the full local data set (the only source used during normal use).
getAll: async function (dbName) {
const db = await openDb(dbName);
return new Promise((resolve, reject) => {
const request = db.transaction(dataStore, "readonly")
.objectStore(dataStore).getAll();
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
},

// Create or update a single item locally.
put: async function (dbName, item) {
const db = await openDb(dbName);
db.transaction(dataStore, "readwrite").objectStore(dataStore).put(item);
},

// Delete a single item locally.
remove: async function (dbName, id) {
const db = await openDb(dbName);
db.transaction(dataStore, "readwrite").objectStore(dataStore).delete(id);
},

// Replace the whole local cache with the server state (used after a pull).
replaceAll: async function (dbName, items) {
const db = await openDb(dbName);
const store = db.transaction(dataStore, "readwrite").objectStore(dataStore);
store.clear();
items.forEach(item => store.put(item));
},

// Track a local mutation so it can be pushed on the next sync.
enqueue: async function (dbName, action) {
const db = await openDb(dbName);
db.transaction(queueStore, "readwrite").objectStore(queueStore).add(action);
},

// Read every pending mutation.
getQueue: async function (dbName) {
const db = await openDb(dbName);
return new Promise((resolve, reject) => {
const request = db.transaction(queueStore, "readonly")
.objectStore(queueStore).getAll();
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
},

// Empty the queue once it has been pushed to the server.
clearQueue: async function (dbName) {
const db = await openDb(dbName);
db.transaction(queueStore, "readwrite").objectStore(queueStore).clear();
}
};
})();

2. The client service (C#)​

The application service reads and writes only in the local store via JsInterop (IJSRuntime, the default Lagoon approach). The server is contacted only in SynchronizeAsync, called on explicit request. Since the script is embedded in main.min.js, no ScriptIncludeAsync is necessary.

Client/Services/InterventionStore.cs
namespace MyApp.Client.Services;

public class InterventionStore
{
private const string DbName = "myapp-offline";

private readonly HttpClient _http;
private readonly IJSRuntime _js;

public InterventionStore(HttpClient http, IJSRuntime js)
{
_http = http;
_js = js;
}

/// <summary>
/// Read the interventions from the local cache. The server is never called here.
/// </summary>
/// <returns>The list of interventions stored locally.</returns>
public async Task<List<InterventionVm>> GetInterventionsAsync()
{
return await _js.InvokeAsync<List<InterventionVm>>("MyApp.OfflineStore.getAll", DbName);
}

/// <summary>
/// Create or update an intervention locally and record the change for the next sync.
/// </summary>
/// <param name="intervention">The intervention to save.</param>
public async Task SaveInterventionAsync(InterventionVm intervention)
{
await _js.InvokeVoidAsync("MyApp.OfflineStore.put", DbName, intervention);
await _js.InvokeVoidAsync("MyApp.OfflineStore.enqueue", DbName,
new PendingAction { Op = "upsert", Item = intervention });
}

/// <summary>
/// Delete an intervention locally and record the change for the next sync.
/// </summary>
/// <param name="intervention">The intervention to delete.</param>
public async Task DeleteInterventionAsync(InterventionVm intervention)
{
await _js.InvokeVoidAsync("MyApp.OfflineStore.remove", DbName, intervention.Id);
await _js.InvokeVoidAsync("MyApp.OfflineStore.enqueue", DbName,
new PendingAction { Op = "delete", Item = intervention });
}

/// <summary>
/// Push the pending local changes to the server, then refresh the local cache.
/// </summary>
public async Task SynchronizeAsync()
{
// 1. Push every local change to the backend.
List<PendingAction> queue = await _js.InvokeAsync<List<PendingAction>>("MyApp.OfflineStore.getQueue", DbName);
foreach (PendingAction action in queue)
{
if (action.Op == "delete")
{
await _http.TryDeleteAsync($"intervention/{action.Item.Id}");
}
else
{
await _http.TryPostAsync("intervention", action.Item);
}
}
await _js.InvokeVoidAsync("MyApp.OfflineStore.clearQueue", DbName);

// 2. Pull the up-to-date server state and replace the local cache.
List<InterventionVm> items = await _http.TryGetAsync<List<InterventionVm>>("intervention");
await _js.InvokeVoidAsync("MyApp.OfflineStore.replaceAll", DbName, items);
}
}

public class PendingAction
{
public string Op { get; set; }

public InterventionVm Item { get; set; }
}

Register the service with the injection container, like other client services.

Client/Services/Registry.cs
public static class Registry
{
public static void RegisterServices(IServiceCollection services)
{
services.AddScoped<InterventionStore>();
}
}

3. The synchronization button​

During use, the page works exclusively on the local cache: no latency, no behavior conditioned by connectivity. Synchronization is triggered by an explicit button. Lagoon does not expose a "back online" event; any network failure at sync time is detected by catching LgHttpFetchException.

Client/Pages/Interventions.razor
@inherits LgPage

<LgButton Text="#Synchronize"
IconName="@IconNames.All.ArrowRepeat"
OnClick="SynchronizeAsync" />
Client/Pages/Interventions.razor.cs
[Inject]
private InterventionStore Store { get; set; }

private List<InterventionVm> _interventions = new();

protected override async Task OnInitializedAsync()
{
// Always read from the local cache — no server call.
_interventions = await Store.GetInterventionsAsync();
}

private async Task SynchronizeAsync()
{
try
{
await Store.SynchronizeAsync();
_interventions = await Store.GetInterventionsAsync();
ShowSuccess("#SyncDone");
}
catch (LgHttpFetchException)
{
// No network: local changes stay queued for a later attempt.
ShowWarning("#SyncOffline");
}
catch (Exception ex)
{
ShowException(ex);
}
}
Key takeaway

Offline-first is not an option you "enable". It is an architecture: the browser cache (IndexedDB) is the source of truth during use, and synchronization is a complete operation with its own strategy (replay order, conflict resolution, server-side idempotence to tolerate partial replay). Size this effort from the design phase for features that must work without network.

See also​