A real local database in Blazor WebAssembly and MAUI: no DbContext, no Include, no migrations
A delivery driver walks into a basement loading dock and the signal dies. A warehouse clerk spends three hours in the one corner where Wi-Fi comes and goes. A user fills in a four-screen form and hits Save at the exact moment your API goes down for a deploy.
The app has to keep working. Which means it needs its own database on the device — not a response cache, but real local storage holding half-finished documents, local state, and a queue of changes waiting to go out. The outbox that flushes itself once the connection comes back.
And that is where the fun starts.
Sound familiar?
"We'll just use key-value, it's simple." localStorage, Preferences, a JSON file. Works
beautifully right up until someone asks for "unfinished orders from the last week, sorted by
priority." Answering that means loading everything into memory and looping. Fine at two hundred
records. At ten thousand, the phone gets warm.
"Let's use EF Core, everyone knows it." And now you have migrations on the client. The app updates, and a migration has to run on a user's device, against their data, with nobody watching. If it goes sideways, you find out from an app store review. Worse, it is not a one-time tax: local state churns far more than server entities, because it is drafts, wizard steps, sync flags.
"The graph is complex anyway." An order with line items, each with attachments and a status
history, plus a customer with an address. On the server you normalized that and wrote Include /
ThenInclude. On the client you need the whole thing every time — the user opened a draft, show me
everything. Miss an Include and you get null where data should be, or an N+1 out of nowhere.
"Fine, serialize the graph into a JSON column." The classic escape hatch: complex stuff goes to
text, simple stuff stays in columns. The graph persists, but querying is over — "status = draft and
total > 10,000" is now a full scan in memory. And your type safety is reduced to hoping
JsonSerializer never meets a field it does not recognize.
Now multiply that by the size of a real app. It is not one entity, it is hundreds of classes, and each one has local state of its own: form drafts, wizard position, active filters, a snapshot for undo, a cached response under a specific key. And that state is not flat — it has nested objects, collections, statuses.
A table per state means hundreds of tables and hundreds of migrations shipping to users' phones. One "key → JSON" table instead is fast to write and comfortably familiar, except:
- type safety is gone: rename a property, and the old JSON quietly deserializes with a
null, and the bug shows up a week later on someone else's device; - search is impossible: "show me unfinished drafts above the limit" means pulling everything out and sifting through it in memory;
- and reading that pile with your own eyes is not a great time either.
Familiar bottom line: half the local-storage code is not business logic, it is bookkeeping for how you store things.
What it looks like instead
The schema is a plain C# class. No DbContext, no migration files, no Include.
[RedbScheme("Order")]
public class OrderProps
{
public string Number { get; set; } = "";
public OrderStatus Status { get; set; }
public decimal Total { get; set; }
public DateTime CreatedAt { get; set; }
public Customer? Customer { get; set; } // nested object
public List<OrderItem> Items { get; set; } = new(); // nested collection
public string[]? Tags { get; set; }
}
Save the whole graph in one line. Load the whole graph in one line. Query nested fields with LINQ that runs in the database, not in memory:
await redb.SaveAsync(order); // the whole graph, Items and Customer included
var draft = await redb.LoadAsync<OrderProps>(id); // the whole graph back, no Include
var pending = await redb.Query<OrderProps>()
.Where(o => o.Status == OrderStatus.Draft && o.Total > 10000m)
.OrderByDescending(o => o.CreatedAt)
.ToListAsync();
Add a property to the class and it simply shows up. Nothing to migrate: no migration files, no
ALTER TABLE, and objects saved earlier keep loading fine.
And this is not a JSON blob: every property lives in a typed, indexed column, so the condition above is a real SQL filter rather than a scan. Strong typing survives all the way down — nested objects, collections, dictionaries.
As for those hundreds of classes: you never list them anywhere. Tag them with [RedbScheme] and
startup finds them in the assembly and sets up the schemas:
// one line for the entire app — for the first class and for the three-hundredth
await redb.InitializeAsync(ensureCreated: true, typeof(OrderProps).Assembly);
A new kind of state is a new class in your code and nothing else. No table, no migration, no registry entry.
Underneath it is ordinary SQLite — the same file you were going to ship anyway. And the same code runs on a server over PostgreSQL or SQL Server, which means the client and the backend end up sharing one model.
What follows is a quick start for both, the everyday operations, and an honest comparison. Not a word about how the provider works inside — this is a piece about using it.
The model for the examples
To keep the code short, everything below uses a note. It all works the same on the graph from the first example, it just reads better this way:
[RedbScheme("Note")]
public class NoteProps
{
public string Title { get; set; } = "";
public string Body { get; set; } = "";
public int Priority { get; set; }
public DateTime CreatedAt { get; set; }
public string[]? Tags { get; set; }
}
RedBase ships three providers: PostgreSQL, SQL Server and SQLite. On the client it is SQLite.
Which package to install
The SQLite provider comes in two editions, and on the client there is effectively no choice to make.
redb.SQLite |
redb.SQLite.Pro |
|
|---|---|---|
| Implementation | part of the logic in a native SQLite extension | pure C# |
| Server, desktop | yes | yes |
| Blazor WebAssembly | no | yes |
| Android, iOS | no | yes |
The Free edition keeps part of its logic in a native SQLite extension, and browsers cannot load those; on mobile that extension is not built at all. Pro is C# from top to bottom, so it runs everywhere.
Pro is free and needs no license key — the whole 3.x line, commercial production included. The package is closed-source, but there is nothing to pay for and nothing to activate: install it and go.
dotnet add package redb.SQLite.Pro
Nothing else to add — redb.Core, SQLite itself and the rest come in transitively. You need .NET 8, 9
or 10. Everything below was verified on 3.5.0.
Quick start: mobile (MAUI)
Mobile first, because it is the easy one: the database is a regular file that survives restarts on its own.
Step 1. Project and package
dotnet workload install maui-android
dotnet new maui -n MyApp
cd MyApp
dotnet add package redb.SQLite.Pro
Building Android-only from Windows? Drop the ios and maccatalyst entries from <TargetFrameworks>,
or restore will demand a workload you do not have.
Step 2. Registration in MauiProgram.cs
using redb.Core.Models.Configuration;
using redb.Core.Pro.Extensions; // AddRedbPro
using redb.SQLite.Pro.Extensions; // UseSqlite
public static MauiApp CreateMauiApp()
{
var builder = MauiApp.CreateBuilder();
builder.UseMauiApp<App>();
// AppDataDirectory is the app's private folder: the file survives restarts
// and updates, and goes away when the app is uninstalled.
var dbPath = Path.Combine(FileSystem.AppDataDirectory, "app.db");
builder.Services.AddRedbPro(options => options
.UseSqlite($"Data Source={dbPath}")
.Configure(c => c.PropsSaveStrategy = PropsSaveStrategy.ChangeTracking));
builder.Services.AddSingleton<RedbBootstrap>();
builder.Services.AddSingleton<MainPage>();
return builder.Build();
}
PropsSaveStrategy.ChangeTracking means "only write the properties that actually changed" instead of
rewriting the whole object. On a phone that saves both time and flash wear.
Step 3. Initialize exactly once
Here is the first thing that trips people up. On a server this happens by itself when the host starts. MAUI does not run hosted services, so you have to call it yourself:
var redb = services.GetRequiredService<IRedbService>();
// Creates the database structure if it is missing, and registers schemas for every
// [RedbScheme] class in the given assembly. You do not enumerate them.
await redb.InitializeAsync(ensureCreated: true, typeof(NoteProps).Assembly);
You can omit the assembly and let it scan everything loaded. On a client, name it explicitly — faster and more predictable.
The second trap: Android recreates the Activity on rotation and when you come back from the
background. Tie initialization to a page event and it runs several times. Tie it to the process
instead — Lazy<Task> does that in one line and behaves under concurrent callers:
public sealed class RedbBootstrap
{
private readonly Lazy<Task> _init;
public RedbBootstrap(IServiceProvider services)
{
_init = new Lazy<Task>(async () =>
{
var redb = services.GetRequiredService<IRedbService>();
await redb.InitializeAsync(ensureCreated: true, typeof(NoteProps).Assembly);
});
}
/// Call before touching the database. Actually runs once per process.
public Task EnsureInitializedAsync() => _init.Value;
}
Step 4. The page
public partial class MainPage : ContentPage
{
private readonly IRedbService _redb;
private readonly RedbBootstrap _bootstrap;
public MainPage(IRedbService redb, RedbBootstrap bootstrap)
{
InitializeComponent();
_redb = redb;
_bootstrap = bootstrap;
}
protected override async void OnAppearing()
{
base.OnAppearing();
await _bootstrap.EnsureInitializedAsync();
CountLabel.Text = $"Notes: {await _redb.Query<NoteProps>().CountAsync()}";
}
}
That is the whole setup. Run dotnet build -f net10.0-android -t:Run, the app opens, the database is
created on first launch and stays on the device until the app is uninstalled.
Release builds use trimming and AOT — the provider handles that, nothing extra to configure. If you crank trimming past the defaults, root the assembly holding your schema classes: they are read through reflection and the linker does not know about them.
Quick start: Blazor WebAssembly
The same code runs in a browser, with three wrinkles. All three fail the same unhelpful way — the project builds clean and then breaks in the browser — so it is worth knowing all of them.
Wrinkle 1. The build needs an extra tool
dotnet workload install wasm-tools
<PropertyGroup>
<WasmBuildNative>true</WasmBuildNative>
</PropertyGroup>
Why: browsers have no OS library loader, so SQLite has to be compiled into the runtime at build
time rather than shipped next to it. Release turns this on by itself; dotnet run and Debug need the
flag. Without it you get a stock runtime with no SQLite in it, and a crash on the first database call.
The first build after this gets noticeably slower — that is the native link step, and it is a one-off. Incremental builds stay quick.
Wrinkle 2. Initialization is manual here too
Same as MAUI, same reason: WebAssemblyHost does not run hosted services.
Wrinkle 3. Persistence is on you
The browser's file system in .NET is memory. While the tab is open the database behaves normally; hit reload and it is gone. RedBase does not hand you a persistence mechanism — deliberately, because the right answer depends on the app: IndexedDB, the Cache API, or OPFS.
Here is a working IndexedDB version. It needs no special build flags and uses the ordinary File API.
One catch that makes naive implementations look fine while they lose data. SQLite in the browser
runs in WAL mode: recent commits land in the app.db-wal companion while the main file stays nearly
empty. Save just app.db and you get a database that "restores" and turns out empty. Both files have
to travel.
wwwroot/js/dbPersistence.js:
const DB_NAME = "myapp-db";
const STORE = "files";
function openIdb() {
return new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, 1);
req.onupgradeneeded = () => req.result.createObjectStore(STORE);
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
export async function load(key) {
const db = await openIdb();
try {
return await new Promise((resolve, reject) => {
const tx = db.transaction(STORE, "readonly");
const req = tx.objectStore(STORE).get(key);
req.onsuccess = () => resolve(req.result ? new Uint8Array(req.result) : null);
req.onerror = () => reject(req.error);
});
} finally { db.close(); }
}
export async function save(key, bytes) {
const db = await openIdb();
try {
await new Promise((resolve, reject) => {
const tx = db.transaction(STORE, "readwrite");
tx.objectStore(STORE).put(new Uint8Array(bytes), key);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
tx.onabort = () => reject(tx.error);
});
} finally { db.close(); }
}
Services/SqliteFilePersistence.cs:
using Microsoft.JSInterop;
using redb.Core.Data;
public sealed class SqliteFilePersistence
{
private readonly IJSRuntime _js;
private readonly string _dbPath;
private IJSObjectReference? _module;
public SqliteFilePersistence(IJSRuntime js, string dbPath)
{
_js = js;
_dbPath = dbPath;
}
private async Task<IJSObjectReference> ModuleAsync()
=> _module ??= await _js.InvokeAsync<IJSObjectReference>("import", "./js/dbPersistence.js");
/// Pull the database out of IndexedDB. Must happen before anything touches the
/// database, or SQLite creates an empty file and there is nothing left to restore.
public async Task RestoreAsync()
{
var module = await ModuleAsync();
foreach (var path in new[] { _dbPath, _dbPath + "-wal" })
{
var bytes = await module.InvokeAsync<byte[]?>("load", path);
if (bytes is { Length: > 0 })
await File.WriteAllBytesAsync(path, bytes);
}
}
/// Push the current state back into IndexedDB.
public async Task PersistAsync(IRedbContext context)
{
// PASSIVE matters. TRUNCATE wants an exclusive lock, and on a single-threaded
// browser there is nobody to release it — the call just hangs.
try { await context.ExecuteAsync("PRAGMA wal_checkpoint(PASSIVE);"); } catch { }
var module = await ModuleAsync();
foreach (var path in new[] { _dbPath, _dbPath + "-wal" })
{
if (File.Exists(path))
await module.InvokeVoidAsync("save", path, await File.ReadAllBytesAsync(path));
}
}
}
Program.cs — order matters here:
const string DbPath = "/app.db";
var builder = WebAssemblyHostBuilder.CreateDefault(args);
builder.RootComponents.Add<App>("#app");
builder.RootComponents.Add<HeadOutlet>("head::after");
builder.Services.AddSingleton(sp =>
new SqliteFilePersistence(sp.GetRequiredService<IJSRuntime>(), DbPath));
builder.Services.AddRedbPro(options => options.UseSqlite($"Data Source={DbPath}"));
var host = builder.Build();
// 1. Restore the files first...
await host.Services.GetRequiredService<SqliteFilePersistence>().RestoreAsync();
// 2. ...and only then touch the database.
var redb = host.Services.GetRequiredService<IRedbService>();
await redb.InitializeAsync(ensureCreated: true, typeof(NoteProps).Assembly);
await host.RunAsync();
Persisting ships the whole file, so do not call it on every write. Sensible moments: after a
meaningful user action, on a timer, on beforeunload.
await Redb.SaveAsync(note);
await Persistence.PersistAsync(Context); // debounce this in a real app
Everyday operations
From here on it is identical on mobile and in the browser. Get the service from DI:
@inject IRedbService Redb
Create
An object is a RedbObject<T> wrapper plus your data in Props. The wrapper has service fields; the
only one you need on day one is name, a human-readable label.
var note = new RedbObject<NoteProps>
{
name = "Buy milk",
Props = new NoteProps
{
Title = "Buy milk",
Body = "And bread",
Priority = 2,
CreatedAt = DateTime.UtcNow,
Tags = ["home", "groceries"]
}
};
long id = await Redb.SaveAsync(note);
SaveAsync hands back the id, and also stamps it onto the object, so note.Id is populated after the
call.
Got several objects? Do not loop. The same method takes a collection and writes it in one batch, returning the ids:
var notes = new List<RedbObject<NoteProps>> { note1, note2, note3 };
List<long> ids = await Redb.SaveAsync(notes);
Read by id
var loaded = await Redb.LoadAsync<NoteProps>(id);
if (loaded is not null)
{
Console.WriteLine(loaded.Props.Title); // "Buy milk"
Console.WriteLine(loaded.Props.Tags![0]); // "home"
}
The object comes back whole, arrays and nested objects included. There is no "forgot to load the related data" failure mode here — properties are always there.
If no object has that id you get null, hence the check.
Update
There is no separate Update. Change the loaded object and save it again:
var note = await Redb.LoadAsync<NoteProps>(id);
note.Props.Priority = 5;
note.Props.Body = "And bread, and yogurt";
await Redb.SaveAsync(note);
With PropsSaveStrategy.ChangeTracking, only those two properties hit the database.
Delete
await Redb.DeleteAsync(note);
Queries
Plain LINQ. Conditions run in the database, not in memory:
// high-priority notes, newest first
var important = await Redb.Query<NoteProps>()
.Where(n => n.Priority >= 3)
.OrderByDescending(n => n.CreatedAt)
.ToListAsync();
// substring search
var found = await Redb.Query<NoteProps>()
.Where(n => n.Title.Contains("milk"))
.ToListAsync();
// date range plus a compound condition
var lastWeek = DateTime.UtcNow.AddDays(-7);
var recent = await Redb.Query<NoteProps>()
.Where(n => n.CreatedAt >= lastWeek && n.Priority > 1)
.ToListAsync();
Paging, counts, existence checks:
var page = await Redb.Query<NoteProps>()
.OrderByDescending(n => n.CreatedAt)
.Skip(20).Take(20)
.ToListAsync();
int total = await Redb.Query<NoteProps>().CountAsync();
bool any = await Redb.Query<NoteProps>().AnyAsync(n => n.Priority == 5);
When you do not need the whole object, take just the fields you want — less data off disk:
var titles = await Redb.Query<NoteProps>()
.Where(n => n.Priority >= 3)
.Select(n => new { n.Props.Title, n.Props.CreatedAt })
.ToListAsync();
Worth noticing, because it catches everyone once: in Where and OrderBy you write properties
directly — n.Priority — while in Select you go through Props: n.Props.Title. In a condition
the parameter is your data; in a projection you get the whole object including service fields
(n.Id, n.name), so Props has to be explicit.
Arrays
An array property is not a delimited string — you can query it:
var home = await Redb.Query<NoteProps>()
.Where(n => n.Tags!.Contains("home"))
.ToListAsync();
Add a field
The most common thing you will do as the app grows. Add the property:
public class NoteProps
{
// ...everything that was here
public bool IsDone { get; set; } // new
}
Done. The same InitializeAsync at startup picks it up. No migration files, no ALTER TABLE, and
objects saved earlier keep loading — the new property simply comes back as its default.
Compare that to the migration workflow: create a migration, review the generated SQL, think about the rollback, ship it to devices and hope it lands cleanly on data you have never seen. That step just does not exist here.
The outbox queue
This is usually the whole reason a local database exists, so here it is end to end. The job: while there is no connection, changes pile up on the device; once it is back, they go out in order with a status you can show.
The queue is a normal class. Note that it holds a typed payload with its own nested structure, not a JSON string:
[RedbScheme("OutboxEntry")]
public class OutboxEntryProps
{
public string Operation { get; set; } = ""; // "order.create", "order.update"
public OutboxState State { get; set; } // Pending, Sending, Failed, Sent
public int Attempts { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime? LastTriedAt { get; set; }
public string? LastError { get; set; }
public OrderProps? Payload { get; set; } // the whole graph
}
Enqueueing is just a save:
await redb.SaveAsync(new RedbObject<OutboxEntryProps>
{
name = $"outbox {order.Number}",
Props = new OutboxEntryProps
{
Operation = "order.create",
State = OutboxState.Pending,
CreatedAt = DateTime.UtcNow,
Payload = order // the nested graph is stored with the entry
}
});
Flushing once you are back online. This is where querying earns its keep: picking the right entries is LINQ, not "load everything and filter in memory."
var batch = await redb.Query<OutboxEntryProps>()
.Where(e => e.State == OutboxState.Pending && e.Attempts < 5)
.OrderBy(e => e.CreatedAt) // strictly in the order they appeared
.Take(20) // in chunks, so you never stall
.ToListAsync();
foreach (var entry in batch)
{
try
{
await api.SendAsync(entry.Props.Operation, entry.Props.Payload!);
entry.Props.State = OutboxState.Sent;
}
catch (Exception ex)
{
entry.Props.Attempts++;
entry.Props.State = OutboxState.Failed;
entry.Props.LastError = ex.Message;
}
entry.Props.LastTriedAt = DateTime.UtcNow;
}
// The whole batch in one call, not one write per entry.
await redb.SaveAsync(batch);
That last line matters: SaveAsync takes a collection and writes it as a batch. The loop keeps only
what is genuinely one-at-a-time — the network call — and the results go to the database in a single
trip. At twenty entries you will not notice. At two thousand you will.
Showing the user what is happening is a query too, not a tally in a loop:
int waiting = await redb.Query<OutboxEntryProps>()
.Where(e => e.State == OutboxState.Pending)
.CountAsync();
var problems = await redb.Query<OutboxEntryProps>()
.Where(e => e.Attempts >= 5)
.ToListAsync();
Try the same on "key → JSON": finding stuck entries means reading the entire queue, deserializing every item and looping. And renaming a field means praying the old records still parse.
Making queries fast: filter on the base fields
A habit worth picking up on day one rather than when the list starts crawling.
Besides your Props, every object has the RedbObject's own fields: Id, ParentId, DateCreate,
plus fast slots — value_string, value_long, value_datetime and friends. They live right in the
object's row, which makes filtering on them the cheapest thing available: it narrows the set before
properties come into play.
You filter them with WhereRedb, which composes happily with regular Where.
The idea is simple: whatever you look things up by most often — a situation key, an external id, a
timestamp — put it in a fast slot as well as in Props. Then fetching state for a given key becomes
an indexed hit.
Writing — fill the slots along with the data:
var key = $"{stateType}:{documentId}"; // "order-draft:12345"
await redb.SaveAsync(new RedbObject<DraftStateProps>
{
name = $"Draft {key}",
value_string = key, // the situation key
value_long = documentId, // external id
value_datetime = DateTimeOffset.UtcNow, // timestamp
Props = new DraftStateProps { /* ... */ }
});
Reading — narrow by slot first, refine by properties after:
// state for a specific key — a hit, not a scan
var draft = await redb.Query<DraftStateProps>()
.WhereRedb(o => o.ValueString == key)
.FirstOrDefaultAsync();
// everything belonging to a document
var byDocument = await redb.Query<DraftStateProps>()
.WhereRedb(o => o.ValueLong == documentId)
.ToListAsync();
// cut by date: clean out what went stale
var threshold = DateTimeOffset.UtcNow.AddDays(-30);
var stale = await redb.Query<DraftStateProps>()
.WhereRedb(o => o.ValueDatetime <= threshold)
.ToListAsync();
// cheap slot filter first, property condition second
var actual = await redb.Query<OutboxEntryProps>()
.WhereRedb(o => o.ValueDatetime > threshold)
.Where(e => e.State == OutboxState.Pending)
.ToListAsync();
Grouping is ParentId. Worth being precise here: this is not just a number, it is a real foreign
key to another object in the same database — to its Id. You cannot stash an identifier from some
other system in it; that is what value_long is for. What you get in return is database-level
integrity and cascade: delete the parent and the children go with it, no manual cleanup.
So the parent has to exist: save the session object (or document, or route) first, take its Id, then
put that Id into parent_id on the children. After which fetching the whole group is one condition:
var groupItems = await redb.Query<DraftStateProps>()
.WhereRedb(o => o.ParentId == sessionId)
.ToListAsync();
// or several groups at once
var manyGroups = await redb.Query<DraftStateProps>()
.WhereRedb(o => o.ParentId != null && sessionIds.Contains(o.ParentId.Value))
.ToListAsync();
Many-to-many without a join table
The same trick removes the reason you would normally introduce a junction table. Say you track membership: users belong to groups, a document belongs to several categories.
Store the link as an object and fill two slots at once: ParentId for one side, value_long for the
other. Now both directions are a single indexed query with no JOIN:
await redb.SaveAsync(new RedbObject<MembershipProps>
{
name = $"member {userId}",
// one side of the link is the parent
parent_id = groupId,
// the other goes into a fast slot
value_long = userId,
// and into key: a unique index on it stops the same link
// from being written twice
key = userId,
Props = new MembershipProps { AssignedAt = DateTimeOffset.UtcNow }
});
// everyone in the group
var members = await redb.Query<MembershipProps>()
.WhereRedb(o => o.ParentId == groupId)
.ToListAsync();
// every group a user belongs to — the reverse query, also JOIN-free
var groups = await redb.Query<MembershipProps>()
.WhereRedb(o => o.ValueLong == userId)
.ToListAsync();
This is not invented for the article: it is exactly how role assignments work in RedBase Identity,
where the link class carries a comment stating that parent_id points at the role, value_long
mirrors the user id for the reverse lookup, and key provides the unique index that makes assigning a
role idempotent without a separate existence check. Adopt the convention in your own classes early and
you will not be rewriting queries later.
Small gotcha: you write the lowercase names (value_string, parent_id, key) and read the
PascalCase ones in WhereRedb (o.ValueString, o.ParentId, o.Key). Same fields.
While we are here: trees
Since ParentId is a link to another object, objects naturally form a hierarchy — and you do not have
to hand-roll it. There is an API for it: load a whole subtree, take direct children only, build the
path to the root for breadcrumbs, move a node with everything under it, ask whether A is a descendant
of B, walk depth-first or breadth-first, select roots only, leaves only, or filter by depth.
// the whole branch in one query
var subtree = await redb.TreeQuery<CategoryProps>(rootId).ToListAsync();
// moving a node — children come along
await redb.MoveObjectAsync(node, newParent);
On a client that is usually a catalogue, a folder tree, an org chart or threaded comments — the stuff you would otherwise assemble with hand-written recursive queries.
And there is quite a bit more
Not to turn this into a reference: beyond what is shown here, RedBase has aggregations and GroupBy,
window functions, lookup lists, polymorphic queries over a class hierarchy, soft delete with
background purge, built-in audit fields (who changed what, when), object ownership and permissions,
and database export/import. All of it behaves the same across all three providers — client-side SQLite
included.
The repository ships a redb.Examples project with 148 runnable examples grouped by topic:
queries, analytics, trees, lists, CRUD. Fastest way to see how a specific thing is done without
reading the docs end to end.
Syncing with a server that also runs RedBase
A pleasant side effect: RedBase is not only SQLite. The same classes run on a server over PostgreSQL or SQL Server. Put the schemas in a shared project that both the client and the backend reference, and the data model becomes literally one model for the whole system.
What changes in practice: there is no translation layer between client and server. No DTOs, no mappers, no separate "sync contract" you have to edit on both sides every time a field changes. The object you pulled out of the local database is the same type the server puts into its own:
// client: pulled from the queue
var entry = ...;
// server: received the same type and saved it
await redb.SaveAsync(order);
Add a field to the shared class and it appears in the local database, in the server database, and in what goes over the wire. No keeping three places in sync, no matching a server migration to a client version.
How it compares
Comparing in one specific role: private local storage for a client app. Not a server database, not an analytics warehouse — the thing that sits on a user's device.
| EF Core + SQLite | Key → JSON | RedBase | |
|---|---|---|---|
| Schema for a new kind of state | entity + migration | nothing | nothing |
| Hundreds of state classes | hundreds of tables and migrations | one table, no types | tag with [RedbScheme] |
| App update | migration runs on the user's device | — | nothing to migrate |
| Nested graph | Include / ThenInclude per level |
all at once, as a blob | all at once, in one line |
| Querying nested fields | JOINs | not possible, memory scan only | LINQ down to SQL |
| Strong typing | yes | gone | yes |
| Raw SQL when you want it | yes | no | yes |
The rows worth expanding on.
Migrations. On a server a migration is a controlled procedure: you apply it, watch it, roll it back. On a client it ships to someone else's device and runs against data you have never seen. The more your local state churns — and it churns more than server entities do — the more often you roll those dice. That step is simply absent here: a new property appears on its own, old objects keep loading.
The graph. With EF, completeness is your job on every single query: miss an Include and you get
an empty collection instead of data; add too many and you drag half the database into memory. On a
client, where you almost always want the whole graph (the user opened a draft), that is a daily tax.
LoadAsync returns the object assembled.
Typing vs JSON. The "key → JSON" route wins exactly once — on day one. After that: rename a field and lose data silently; need a search and enjoy your full scan; want to eyeball what is actually stored and good luck. RedBase gives you the same "just save the object" feeling, but the properties sit in typed columns and take part in queries.
Raw SQL. Worth stating plainly, because the question comes up immediately: what if I want a flat table for trends or aggregates? Nobody took it away — the same context runs arbitrary SQL, including against your own tables:
@inject IRedbContext Context
var total = await Context.ExecuteScalarAsync<long>(
"SELECT COUNT(*) FROM my_metrics WHERE bucket = '2026-08'");
await Context.ExecuteAsync(
"CREATE TABLE IF NOT EXISTS my_metrics (bucket TEXT, value REAL)");
So it is not "objects or SQL" — it is objects by default, SQL where SQL fits better.
When sticking with EF Core makes more sense. If the app already has an EF model shared with the
server and there is no reason to rewrite it. Or if the local database has to have a specific physical
schema because something other than your app reads it. Outside of those, you are paying in migrations
and Include for a schema nobody outside will ever see.
What about MongoDB? There is no MongoDB on a client — it is a server. If what appeals to you is the document model ("just save the object"), RedBase gives you that feeling on top of ordinary SQLite: transactions, strong typing, and LINQ instead of a bespoke query language. Local document stores like LiteDB are closer in spirit, but there you are back to choosing between "I store documents" and "I can search."
Things worth knowing upfront
A few things that are better learned here than in production.
SQLite is single-writer. That is SQLite, not the wrapper. Rarely an issue for a client app, but if you plan to write from several threads, keep transactions short.
One tab per database in the browser. Two tabs are two independent instances of your app, each with
its own copy of the file in memory. Last one to save wins. If you need multi-tab, coordinate through
BroadcastChannel or lock the second tab out.
The browser is single-threaded. A heavy query freezes the UI, so do not pull everything onto the
page: Take and paging are about responsiveness here, not aesthetics.
Download size. The managed assemblies come to roughly two megabytes, plus SQLite inside the runtime. Fine for internal tools and offline-first apps, not for a landing page. Brotli is mandatory.
First launch in the browser takes a second or two while the database structure is created. Show a spinner.
Exact money arithmetic. decimal is stored approximately in SQLite. If you need to-the-cent
precision, that is a SQLite constraint rather than a wrapper one — factor it into your choice of
storage.
Wrapping up
On mobile it comes down to three things: install the package, point it at a file in the app's data directory, and call initialization once. After that it is ordinary C# with LINQ.
In the browser you add three more: wasm-tools with a build flag, the same manual initialization, and
your own IndexedDB persistence layer — where the things to remember are "move both database files" and
"never use the TRUNCATE checkpoint."
What you get for that is one data model across both clients, and queries instead of looping over collections in memory.
Docs and examples: redbase.app. Sources, templates and the issue tracker: github.com/redbase-app/redb.
More of my writing: redbase.app/articles, and on dev.to.