What if your web application didn't need a backend for data storage? What if every read and write happened instantly, with no network latency? What if the app worked perfectly offline, and user data never left their device?
This is the promise of offline-first applications, and WitDatabase makes it possible in Blazor WebAssembly.
The magic ingredient is IndexedDB — a key-value database built into every modern browser. WitDatabase wraps it with a familiar .NET API, giving you transactions, the B+Tree storage engine, and even encryption — all running client-side in WebAssembly.
In this sample, we build a practical application with two features:
- Contacts — A contact manager with categories, favorites, and search
- Notes — A note-taking app with colors, tags, and pinning
The data persists across page refreshes, browser restarts, and even system reboots. It's stored locally in the browser, never touching a server. For privacy-focused applications, personal tools, or apps that need to work offline, this architecture is transformative.
Let's see how it works.
Prerequisites & Setup
You'll need the .NET 9.0 or 10.0 SDK and a modern browser with IndexedDB support.
# Clone and run
git clone https://github.com/dmitrat/WitDatabase.git
cd WitDatabase/Samples/OutWit.Database.Samples.BlazorWasm
dotnet run
Open https://localhost:7173 in your browser. You'll see a dashboard with database statistics and quick actions. Navigate to Contacts or Notes to explore the features.
Browser Compatibility
| Browser | Minimum Version |
|---|---|
| Chrome | 80+ |
| Edge | 80+ |
| Firefox | 75+ |
| Safari | 14+ |
IndexedDB is a web standard supported by all major browsers. Storage limits vary (typically 50-100MB or more), but for personal data applications, this is plenty.
How It Works: IndexedDB Storage
IndexedDB is a low-level API for storing structured data in the browser. It's asynchronous, supports transactions, and persists data even after the browser closes. Every modern browser includes it.
WitDatabase connects to IndexedDB through JavaScript interop. When you call PutAsync() in C#, the data flows through WebAssembly to a small JavaScript bridge, then into IndexedDB. When you call GetAsync(), it flows back.
[[Svg Src="./witdatabase-blazor-indexeddb-stack.svg" Alt="witdatabase-blazor-indexeddb-stack"]]
The key insight is that WitDatabase's storage layer is pluggable. On the server, it uses file storage. In the browser, it uses IndexedDB. The rest of the engine — B+Tree, transactions, caching — works identically in both environments.
This means your data survives:
- Page refreshes
- Closing and reopening the browser
- System restarts
- Browser updates
The data stays in the browser until explicitly deleted by the user or your application.
Application Architecture
The sample uses the MVVM pattern (Model-View-ViewModel) with a layered architecture:
[[Svg Src="./witdatabase-blazor-mvvm-layers.svg" Alt="witdatabase-blazor-mvvm-layers"]]
Why MVVM for Blazor?
Blazor components can become messy when they contain both UI markup and business logic. MVVM separates these concerns:
- Views (Razor pages) handle presentation
- ViewModels handle state and logic
- Models represent data
The ViewModel exposes observable properties. When they change, the UI updates automatically. Commands encapsulate actions like "save" or "delete." This makes the code testable and maintainable.
MudBlazor UI
The sample uses MudBlazor — a Material Design component library for Blazor. It provides cards, dialogs, buttons, form inputs, and more — all styled consistently. This isn't WitDatabase-specific, but it makes the sample look professional without custom CSS.
Database Initialization
Unlike server-side applications where you can open a database synchronously at startup, Blazor WebAssembly requires async initialization. The DatabaseService handles this:
public async Task InitializeAsync(CancellationToken cancellationToken = default)
{
_database = await new WitDatabaseBuilder()
.WithIndexedDbStorage(DATABASE_NAME, _jsRuntime)
.WithBTree()
.WithTransactions()
.BuildAsync(cancellationToken);
}
The key differences from server-side initialization:
WithIndexedDbStorageinstead ofWithFilePath— tells WitDatabase to use the browser's IndexedDB_jsRuntimeparameter — the JavaScript interop runtime, injected by BlazorBuildAsyncinstead ofBuild— initialization is asynchronous because IndexedDB operations are async
The DatabaseService is registered as a singleton in Program.cs. It's shared across all components and viewmodels.
Initialization Flow
When the app starts:
- Blazor loads the WebAssembly runtime
- Components render with "loading" state
- The Dashboard page calls
DatabaseService.InitializeAsync() - WitDatabase opens (or creates) the IndexedDB database
- The UI updates to show "Database ready"
If the user navigates to Contacts before initialization completes, the page waits. The database is initialized once, then reused for the application's lifetime.
Data Models: Contacts & Notes
The sample includes two domain models that demonstrate different features.
Contact
A contact has basic info plus categorization:
- Name, Email, Phone — standard contact fields
- Category — Family, Friend, Work, or Other
- IsFavorite — starred contacts appear first
- Avatar — generated from initials
Categories enable filtering ("show only Work contacts"), and favorites enable prioritization.
Note
A note is more creative:
- Title, Content — the actual note text
- Color — Yellow, Blue, Green, Purple, Red, or Gray
- IsPinned — pinned notes stick to the top
- Tags — array of strings for organization
Colors make notes visually distinct. Tags enable cross-cutting organization ("all notes tagged 'urgent'").
Storage Format
Both models are serialized to JSON and stored in WitDatabase as key-value pairs. The key includes a prefix and a zero-padded ID:
contact:0000000001 → {"Name":"Alice","Email":"alice@example.com",...}
contact:0000000002 → {"Name":"Bob",...}
note:0000000001 → {"Title":"Shopping List","Color":"Yellow",...}
The prefix enables efficient range queries — "get all contacts" scans from contact: to contact:\x7F. The zero-padding ensures lexicographic ordering matches numeric ordering.
Generic Repository Pattern
The DatabaseService provides generic CRUD operations that work with any entity type.
Saving Entities
public async Task<T> SaveAsync<T>(string prefix, int id, T entity, CancellationToken ct = default)
{
var key = Encoding.UTF8.GetBytes(
Loading...
quot;{prefix}{id:D10}");
var value = JsonSerializer.SerializeToUtf8Bytes(entity, JsonOptions);
await _database.PutAsync(key, value, ct);
await _database.FlushAsync(ct);
return entity;
}
The key is the prefix plus a 10-digit zero-padded ID. The value is JSON. After writing, FlushAsync ensures the data is persisted to IndexedDB.
Reading All Entities
public async Task<List<T>> GetAllAsync<T>(string prefix, CancellationToken ct = default)
{
var results = new List<T>();
var startKey = Encoding.UTF8.GetBytes(prefix);
var endKey = Encoding.UTF8.GetBytes(prefix + "\x7F");
await foreach (var (_, value) in _database.ScanAsync(startKey, endKey, ct))
{
var entity = JsonSerializer.Deserialize<T>(value, JsonOptions);
if (entity != null) results.Add(entity);
}
return results;
}
The ScanAsync method returns all key-value pairs in a range. By using the prefix as the start key and the prefix plus a high character as the end key, we get all entries for that entity type.
Auto-Increment IDs
Since there's no SQL AUTOINCREMENT, we manage sequences manually:
public async Task<int> GetNextIdAsync(string prefix, CancellationToken ct = default)
{
var seqKey = Encoding.UTF8.GetBytes(
Loading...
quot;seq:{prefix}");
var value = await _database.GetAsync(seqKey, ct);
var nextId = value == null ? 1 : BitConverter.ToInt32(value) + 1;
await _database.PutAsync(seqKey, BitConverter.GetBytes(nextId), ct);
return nextId;
}
Each prefix has a sequence counter stored under seq:contact: or seq:note:. This mimics auto-increment behavior.
ViewModel with MVVM Toolkit
The CommunityToolkit.Mvvm library provides source generators that eliminate boilerplate. You declare intent; the toolkit generates the implementation.
Observable Properties
Instead of writing full property definitions with INotifyPropertyChanged, you write:
[ObservableProperty]
private List<Contact> _contacts = [];
[ObservableProperty]
private string _searchText = string.Empty;
The toolkit generates public properties Contacts and SearchText that automatically notify the UI when changed.
Computed Properties
You can declare dependencies between properties:
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(FilteredContacts))]
private string _searchText = string.Empty;
public IEnumerable<Contact> FilteredContacts =>
Contacts.Where(c => c.Name.Contains(SearchText, StringComparison.OrdinalIgnoreCase));
When SearchText changes, the toolkit also notifies that FilteredContacts changed. The UI updates automatically.
Commands
Actions are encapsulated as commands:
[RelayCommand]
private async Task LoadAsync()
{
IsLoading = true;
Contacts = await _repository.GetAllAsync();
IsLoading = false;
}
This generates a LoadCommand property that can be bound to buttons or called from code. The command handles async execution automatically.
Connecting to Blazor
In the Razor page, we subscribe to property changes:
@code {
protected override async Task OnInitializedAsync()
{
ViewModel.PropertyChanged += (_, _) => InvokeAsync(StateHasChanged);
await ViewModel.LoadCommand.ExecuteAsync(null);
}
}
When any ViewModel property changes, Blazor re-renders the component. This bridges the MVVM world with Blazor's rendering model.
Features Demo
The sample includes three pages that demonstrate different aspects of the architecture.
Dashboard
The home page shows database statistics and provides quick actions:
- Contact count and favorite count — aggregated from the data
- Note count and pinned count — same for notes
- Seed Data button — populates the database with sample entries
- Clear All button — removes all data (with confirmation)
- Connection status — shows whether IndexedDB is connected
The Dashboard also handles database initialization. When you first load the app, it shows "Connecting to IndexedDB..." until the database is ready.
Contacts Page
A full contact manager with:
- Contact cards showing avatar, name, email, phone, and category
- Category filter dropdown — show All, Family, Friends, Work, or Other
- Favorites filter toggle — show only starred contacts
- Search — filter by name in real-time
- Add/Edit/Delete — full CRUD through dialog modals
The filtering happens in the ViewModel. The FilteredContacts property combines the search text, category filter, and favorites filter into a single LINQ query. Changes are instant because all data is local.
Notes Page
A colorful note-taking interface:
- Color-coded cards — each note displays in its assigned color
- Pinned section — pinned notes appear at the top in a separate row
- Tags — displayed as chips on each note
- Rich content — multiline text with proper formatting
- Add/Edit/Delete — same CRUD pattern as contacts
The Notes page demonstrates how visual variety (colors, pinning) can make data more scannable without complex filtering UI.
The Offline Experience
Try this: load the app, add some contacts and notes, then disconnect from the internet and refresh the page. Everything still works. Your data is stored locally in IndexedDB — no server round-trip needed.
This is the essence of offline-first: the network is optional for core functionality.
When to Use This Architecture
Blazor WebAssembly with WitDatabase is ideal for specific scenarios:
Perfect for:
- Personal productivity tools (notes, tasks, journals)
- Privacy-sensitive applications (data never leaves the device)
- Offline-first apps (field work, travel, unreliable connectivity)
- Single-user tools (no collaboration needed)
- Prototypes and demos (no backend to deploy)
Not ideal for:
- Multi-user applications (data stays on one device)
- Large datasets (browser storage has limits)
- Applications requiring server-side processing
- Apps that need real-time sync across devices
The key question is: does the data belong to the user, and does it need to stay on their device? If yes, this architecture shines.
Adding Encryption
For sensitive data, you can enable encryption:
_database = await new WitDatabaseBuilder()
.WithIndexedDbStorage(DATABASE_NAME, _jsRuntime)
.WithBTree()
.WithBouncyCastleEncryption(userPassword) // ChaCha20-Poly1305
.WithTransactions()
.BuildAsync();
The data is encrypted before it reaches IndexedDB. Even if someone inspects the browser's storage, they see only encrypted bytes.
Summary
In this tutorial, we built an offline-first Blazor WebAssembly application:
Concept
What We Learned
IndexedDB Storage
WitDatabase works in the browser via JS interop
MVVM Architecture
Separates UI from logic for testable, maintainable code
Observable Properties
CommunityToolkit.Mvvm generates change notification
Key-Value Storage
Prefixed keys enable efficient range queries
Offline-First
Data persists locally, no server required
This architecture is transformative for the right use cases. When data belongs to the user and should stay on their device, WitDatabase in Blazor WASM delivers a seamless experience.
Get the Code
git clone https://github.com/dmitrat/WitDatabase.git
cd WitDatabase/Samples/OutWit.Database.Samples.BlazorWasm
dotnet run
Questions or feedback? Open an issue on GitHub!