In practice: Tutorial Ch 8 · recipe turn on production SQLite · cheat sheet.
SQLite's stock configuration is tuned for a single embedded process, not a web app. Out of the box a
connection uses the rollback journal (not WAL), does not enforce foreign keys, has no
busy_timeout, and fsyncs on every commit — so the moment two requests write at once you get
database is locked, and a bad delete silently orphans rows. The fix is well understood: apply a tuned
pragma set to every connection. Rask.SQLite does exactly that, applied to every connection you
open, so you get correct, concurrent, production-ready SQLite by default.
The runnable reference is samples/Rask.Example.Sqlite.
Tuned like this, SQLite is a real production database, not a test double — and that changes the shape of
what one person has to run. The database is a file on the same box as the app: no separate database
tier to provision and pay for, no network hop on every query, no connection pool to a remote server to
size. Reads don't block the writer (WAL), writes wait politely instead of failing (busy_timeout), and
continuous backup streams the file off-box so durability doesn't
depend on that one machine. The result is the One Person Framework payoff: one server runs the whole
product — app, data, and background work — with nothing to rent or wire together. See
the doctrine for the bigger picture, and
Limitations & when to outgrow SQLite for the honest edges.
Two packages:
Rask.SQLiteis the pragma engine — it depends only onMicrosoft.Data.Sqlite, is reflection-free, and so works server-side, on mobile, and under trimming/AOT.Rask.SQLite.EntityFrameworkCoreadds the one-lineUseRaskSqlite(...)for EF Core (and pulls inMicrosoft.EntityFrameworkCore.Sqlite). Neither needs the rest of Rask.
dotnet add package Rask.SQLitebuilder.Services.AddRaskSqlite($"Data Source={dbPath}");
// inject IRaskSqliteConnectionFactory, then:
await using var connection = await factory.CreateOpenAsync(ct); // pragmas already applieddotnet add package Rask.SQLite.EntityFrameworkCoreUseRaskSqlite is a drop-in replacement for UseSqlite — it configures the provider and registers
the pragma interceptor:
builder.Services.AddDbContextFactory<AppDb>(o =>
o.UseRaskSqlite($"Data Source={dbPath}"));The defaults are the battle-tested production set for a web-facing SQLite database:
| Pragma | Default | Why |
|---|---|---|
journal_mode |
WAL |
readers don't block the writer — the big concurrency win |
synchronous |
NORMAL |
the safe, fast pairing with WAL |
foreign_keys |
ON |
SQLite leaves referential integrity off otherwise |
busy_timeout |
5000 ms |
wait out a write lock instead of throwing database is locked |
cache_size |
2000 pages (~8 MB) |
fewer disk reads per connection |
mmap_size |
128 MiB |
memory-mapped I/O |
journal_size_limit |
64 MiB |
cap WAL growth |
temp_store |
unset | left on disk by default; opt in with SqliteTempStore.Memory |
Override any of them, or set one to null to leave SQLite's own default:
o.UseRaskSqlite($"Data Source={dbPath}", p =>
{
p.BusyTimeout = TimeSpan.FromSeconds(10);
p.CacheSize = -20_000; // negative ⇒ KiB, so 20 MB
p.TempStore = SqliteTempStore.Memory;
p.MmapSize = null; // leave SQLite's default
});Only journal_mode=WAL persists in the database file header. Every other pragma
(foreign_keys, busy_timeout, synchronous, cache_size, …) is per connection and does not
survive the connection closing. Because Microsoft.Data.Sqlite pools connections, a reused
connection is a fresh open with the stock per-connection defaults — so the pragmas must be re-applied
on every open. Rask.SQLite does exactly that: an EF Core ConnectionOpened interceptor
(RaskSqliteConnectionInterceptor) and, for raw ADO.NET, a StateChange hook on the factory's
connections. Running them once at startup would silently lose them on the next pooled connection.
The other half of the concurrency story is how you take the write lock. A deferred BEGIN
becomes a reader on its first SELECT and only upgrades to a writer on its first write — so two
read-then-write transactions can each hold a read lock and then dead-lock trying to upgrade. That
SQLITE_BUSY is unretryable: SQLite doesn't even invoke the busy handler, because retrying can
never win. BEGIN IMMEDIATE takes the write lock up front, turning that dead-lock into a plain,
waitable lock wait.
The established fix is to wait on that lock with a busy handler that yields instead of blocking,
retrying at a constant 1 ms interval (not exponential backoff, which has far worse tail latency).
Rask.SQLite implements both halves.
ExecuteInImmediateTransactionAsync runs your work inside a BEGIN IMMEDIATE transaction and
acquires the write lock through the raw sqlite3 handle with the native busy handler off — so the
only waiting is an await Task.Delay at the fair interval, which frees the thread rather than
blocking it inside native code. It commits when your callback returns and rolls back if it throws:
await factory.ExecuteInImmediateTransactionAsync(async (connection, ct) =>
{
await using var cmd = connection.CreateCommand();
cmd.CommandText = "INSERT INTO WriteLogs (Note) VALUES ($note);";
cmd.Parameters.AddWithValue("$note", note);
await cmd.ExecuteNonQueryAsync(ct);
});Tune the retry when registering (defaults: 5 s timeout, 1 ms interval):
builder.Services.AddRaskSqlite($"Data Source={dbPath}",
configureRetry: r =>
{
r.Timeout = TimeSpan.FromSeconds(10);
r.PollInterval = TimeSpan.FromMilliseconds(1);
});For a transaction you drive yourself, connection.BeginImmediate() gives you a SqliteTransaction
that took the write lock up front (its wait, though, blocks the thread inside Microsoft.Data.Sqlite —
use ExecuteInImmediateTransactionAsync when you want the non-blocking retry).
Because the lock is taken through the pooled native handle, the path is defensive about connection
reuse: it clears a leaked transaction before every BEGIN IMMEDIATE attempt, and never hands a
mid-transaction handle back to the pool. Before every attempt rather than once, because a transaction can
appear between passes as well as arrive with the handle — an extended SQLITE_BUSY (BUSY_SNAPSHOT,
BUSY_RECOVERY, which the primary result code hides) can leave BEGIN's own transaction open, and
retrying BEGIN inside it fails with the non-retryable "cannot start a transaction within a transaction".
Both rollbacks — the one before each attempt and the one that cleans up on the way out — go through the
same retry, so a rollback blocked by a still-active statement costs a pass instead of poisoning the next
lease. If a statement genuinely fails it throws a SqliteException carrying the extended result code, the
autocommit state on entry to the attempt and the attempt number, so a rare failure is attributable rather
than an opaque SQLite Error 1: 'not an error' — and so a failure on the hundredth pass reads differently
from one on the first.
Your callback can run more than once. SQLite may roll a transaction back on its own when a
contended COMMIT is answered with SQLITE_BUSY — sqlite3_get_autocommit is the only way to find
out, and the transaction is simply gone. Retrying the COMMIT alone would then meet "cannot commit - no
transaction is active", so the whole transaction is run again from BEGIN instead, because everything
the callback wrote went with the rollback. Re-running stops at the same Timeout, measured from entry,
after which the loss is surfaced as a SqliteException with SQLite's own SQLITE_ABORT_ROLLBACK. So:
- Build the callback's commands from state it is handed, not state it consumes, so a second pass writes the same rows.
- Keep side effects that must not repeat — sending mail, calling a service, incrementing a counter in memory — outside the transaction.
- Do not issue
COMMIT,ROLLBACKorENDinside the callback. If the transaction ends while the callback is running, whether its writes were kept or discarded cannot be told apart, so that case is reported rather than retried.
Pass configureRetry (even empty) to register a fair-interval execution strategy so SaveChanges
and queries retry on SQLITE_BUSY/SQLITE_LOCKED:
o.UseRaskSqlite($"Data Source={dbPath}", configureRetry: _ => { });Enabling it turns SQLite's native busy handler off (busy_timeout=0) and lowers
Microsoft.Data.Sqlite's own command timeout so the async strategy owns the waiting. Two caveats:
- EF Core issues every command through Microsoft.Data.Sqlite, whose busy-retry is synchronous, so a contended attempt can block a thread for up to ~1 s before the fair-interval strategy takes over. The raw path above has no such floor.
- The implicit
SaveChangestransaction staysDEFERRED(a write-only batch already takes the write lock on its first statement). For a read-then-write transaction, wrap it inBeginImmediate(and, as with any retrying strategy, insideIExecutionStrategy.ExecuteAsync).
Everything above is a claim. This section is the evidence: a load harness
(benchmarks/Rask.Benchmarks.Sqlite) drives sustained concurrent load against a real database file and
reports throughput, tail latency and error counts. Numbers below are one machine (Apple M4, .NET 10, SSD),
15s per level. Your absolute numbers will differ; the relationships are the point, and they are all measured
in the same process on the same box in the same run.
How to read these. The harness is closed-loop — each virtual user (VU) keeps one operation in flight — so latency is service time under N concurrent clients. Reproduce any row with
dotnet run -c Release --project benchmarks/Rask.Benchmarks.Sqlite -- all.
One INSERT per operation, all four write paths against one WAL database.
| VUs | path | ops/s | p50 | p99 | max |
|---|---|---|---|---|---|
| 1 | ExecuteInImmediateTransactionAsync |
74,145 | 0.01 ms | 0.02 ms | 30 ms |
| 1 | BEGIN IMMEDIATE + busy_timeout |
93,800 | 0.01 ms | 0.02 ms | 32 ms |
| 32 | ExecuteInImmediateTransactionAsync |
68,958 | 0.01 ms | 16 ms | 174 ms |
| 32 | BEGIN IMMEDIATE + busy_timeout |
18,186 | 0.03 ms | 0.05 ms | 15,937 ms |
| 128 | ExecuteInImmediateTransactionAsync |
43,441 | 0.01 ms | 68 ms | 408 ms |
| 128 | BEGIN IMMEDIATE + busy_timeout |
16,772 | 0.03 ms | 0.68 ms | 15,837 ms |
Two honest results here, and neither is "the new thing wins everywhere":
- Uncontended, the native path is faster (94k vs 74k ops/s). Taking the lock through the raw handle and running a managed retry loop is not free. If you have exactly one writer, you are paying ~21% for insurance.
- Contended, the native path's median still looks great — its p50 and p99 beat the non-blocking path, because most writers take the lock immediately. The distribution is bimodal: the ones that don't get stuck behind a thread-blocking wait for nearly 16 seconds. The non-blocking path trades a worse p99 for a worst case that stays bounded (174 ms at 32 VUs, 408 ms at 128) — a ~92× better max — while also sustaining ~3.8× its throughput at 32 VUs.
That is the trade the fair-interval retry actually makes: not more throughput, a bounded tail — plus the
freed thread, which a database-only benchmark cannot show but a web server feels (see
SqliteConcurrencyStressTests, where 400 writers complete on an 8-thread pool). If your p99 matters more
than your p99.9 and you never starve threads, busy_timeout alone is defensible. If a 15-second request is
unacceptable, it is not.
The headline WAL claim, with the control it needs — the same reads in DELETE (rollback-journal) mode, which
is what SQLite does if you don't set journal_mode:
| readers | journal | writer? | ops/s | p99 |
|---|---|---|---|---|
| 32 | WAL | no | 535,877 | 0.34 ms |
| 32 | WAL | yes | 283,936 | 1.17 ms |
| 32 | DELETE | yes | 2,989 | 236 ms |
| 128 | WAL | no | 460,200 | 4.39 ms |
| 128 | WAL | yes | 393,744 | 4.91 ms |
| 128 | DELETE | yes | 2,553 | 1,039 ms |
With WAL, a writer hammering the same file costs readers ~47% of throughput at 32 readers (and only ~14% at 128), leaving p99 near the idle baseline. Without it, the same readers collapse to ~1% of their WAL throughput with a p99 two to three orders of magnitude worse — readers and the writer take turns excluding each other. This one pragma is worth ~95× on read throughput under a concurrent writer at 32 readers, and ~154× at 128.
10,000 seeded rows, a list page (ORDER BY created_at DESC LIMIT 20) plus a row fetch for reads, an insert
for writes:
| VUs | path | ops/s | p50 | p99 |
|---|---|---|---|---|
| 32 | raw ADO | 99,054 | 0.02 ms | 10 ms |
| 32 | EF Core | 26,273 | 0.09 ms | 0.86 ms |
| 128 | raw ADO | 52,765 | 0.09 ms | 65 ms |
| 128 | EF Core | 32,610 | 0.19 ms | 151 ms |
One process, one file, one disk: ~99k operations/second of realistic mixed traffic at a p99 of 10 ms, or ~26k/second through EF Core. For scale, that is far past what a single application server will ask of it — which is the point: SQLite is not the bottleneck you should be designing around.
One caveat reported rather than buried: the EF mixed arm can rarely throw
SQLITE_BUSYfromSqliteConnection.Close()— i.e. from disposing theDbContext, after the work has committed. This is not lock contention, andbusy_timeoutcannot fix it. When Microsoft.Data.Sqlite returns a pooled connection it runsDeactivate(), which un-registers EF Core's built-in helper functions (ef_add,regexp, theEF_DECIMALcollation, …) viasqlite3_create_function(name, null). SQLite refuses that withSQLITE_BUSY— "unable to delete/modify user-function due to active statements" — if any prepared statement is still active on the connection, which happens when a reader was GC-collected but its statement finalizer has not run yet (Close()only finalizes commands it still holds a live reference to). Heavy concurrent read/write churn plus a gen2 GC at the wrong instant is what surfaces it. It is an upstream Microsoft.Data.Sqlite pool-return behaviour, present for any EF Core SQLite app that registers functions — not specific to Rask, and the raw ADO path (no EF functions) shows nothing equivalent. If it bites you,Pooling=Falseon the connection string removes it (no pooled return means noDeactivate), at the cost of re-applying the pragmas on every open. Full deterministic reproduction and analysis are in the harness baselines README.
32 VUs of mixed traffic for 90 seconds, sampled per 10-second window:
- The WAL sawtooths and stays small. It peaked at ~24 MiB against a ~27 MiB database, and latency stayed
flat. SQLite auto-checkpoints at ~1,000 pages (~4 MB), so
journal_size_limit's 64 MiB cap never engages in a healthy database. It is insurance, not a working limit. - A single leaked read transaction is what actually kills you. Hold one
BEGIN; SELECT …;open for the run and the WAL grows to 3.16 GB in 90 seconds — against a 0.5 MiB database. A reader pins the WAL's oldest needed frame, checkpointing cannot reclaim, andjournal_size_limitdoes not stop it: that limit truncates the WAL after a checkpoint, so it cannot cap growth while a checkpoint can't run. This is correct SQLite behaviour, and the reason a long-running report or a forgotten transaction is a disk-space incident. It truncates back the moment the reader commits.
WAL mode (now on by default) is exactly what Litestream needs to
continuously replicate a SQLite database to object storage. The companion package
Rask.SQLite.Litestream supervises the Litestream sidecar from inside your app — no separate
container to orchestrate:
dotnet add package Rask.SQLite.Litestreamvar dbPath = "/data/app.db";
builder.Services.AddRaskSqliteLitestream(o =>
{
o.DatabasePath = dbPath;
o.ReplicaUrl = "s3://my-bucket/app"; // or gcs://, abs:// (Azure Blob), file:///backups/app
// o.ExecutablePath = "/usr/local/bin/litestream"; // if it isn't on PATH
});
builder.Services.AddDbContextFactory<AppDb>(o => o.UseRaskSqlite($"Data Source={dbPath}"));
var app = builder.Build();
// Restore from the replica BEFORE opening the DB — a no-op if the file already exists locally.
await app.Services.RestoreSqliteFromLitestreamAsync();
// ... EnsureCreated / migrate / seed ...
app.Run();AddRaskSqliteLitestream registers a hosted BackgroundService that runs litestream replicate for
the lifetime of the process and stops it on shutdown — sending a graceful interrupt so the last WAL
frames flush before a force-kill (ShutdownGracePeriod, default 10s) — one rung of
the shutdown ladder, which it must fit inside or the host stops
waiting for it. The litestream binary is
driven through CliWrap; a backup failure is logged at Critical
but never crashes the app it protects. Point ConfigPath at a full litestream.yml for multiple
databases or custom retention.
A Critical log line tells you when replication broke; nothing tells you it's healthy. Resolve the
LitestreamStatus singleton to read that directly:
app.MapGet("/health/backup", (LitestreamStatus status) => status.Current);| Property | Meaning |
|---|---|
IsReplicating |
true while litestream replicate is running. Continuous backup only protects you while this is true. |
LastStartedAt / LastExitedAt |
When the current (or most recent) run started and ended, UTC. |
RestartCount |
How many times replication has been restarted. Above zero means backups were interrupted; climbing means they're flapping. |
LastExitCode |
The exit code of the most recent run — null if it never launched. |
LastError |
Why the most recent run failed to launch, if it did. |
A clean shutdown isn't a failure: it clears IsReplicating without counting a restart.
By default the package downloads the litestream binary at build/publish time and drops it next to
your app, so there's nothing to install — the default ExecutablePath finds it automatically. The
binary for the target runtime (Linux x64/arm64/armv7, macOS x64/arm64, Windows x64/arm64) is fetched
once into a per-user cache (~/.rask/litestream/<version>/<rid>), SHA-256-verified against a pinned
checksum, and reused by later builds. Knobs (in your project or with -p:):
| Property | Purpose |
|---|---|
RaskLitestreamDownload |
false to skip the download (air-gapped builds); provide your own binary via ExecutablePath. |
RaskLitestreamVersion |
Pin a different litestream version (also set RaskLitestreamSha256). |
RaskLitestreamSha256 |
The expected checksum when you override the version. |
Building on a platform litestream doesn't ship (or with the download off)? Install
litestreamyourself and pointExecutablePathat it — a bundled binary next to the app always wins, otherwise a bare"litestream"falls back toPATH.
This is the scenario Litestream was built for, and it works well — but SQLite has a hard constraint you must respect:
- Keep the database on local disk, not the mounted share. App Service Linux mounts
/homeover a network filesystem (Azure Files/CIFS) whenWEBSITES_ENABLE_APP_SERVICE_STORAGEis on. SQLite WAL needs real filesystem locking + shared memory and does not work over a network share — you getdisk I/O error/ corruption. Put the DB on a local path (e.g. under/tmpor the container's own filesystem) and let Litestream replicate it to durable storage. - Replicate to Azure Blob with an
abs://container/pathreplica URL (Litestream reads the Azure credentials from environment variables). - Run a single instance. Litestream assumes one writer — set scale-out to 1. Two instances writing their own local databases would diverge the replica.
- Enable Always On so the app isn't unloaded when idle. On every cold start / redeploy the local
disk is empty, and
RestoreSqliteFromLitestreamAsync()pulls the latest replica back before the app opens the database. - Graceful shutdown is handled for you. App Service recycles the container with
SIGTERM; the hosted service interrupts Litestream and lets it flush withinShutdownGracePeriod, so you don't lose the last writes on a redeploy. One caveat worth knowing: withServicesStopConcurrently(what the scaffold sets, so the pillars' grace periods overlap rather than sum), Litestream stops alongside a job that is still finishing inside its grace — so the very last rows such a job writes may not reach the replica. Sequential stop wouldn't reliably help, since the order is reverse-registration; and the exposure only matters if you lose the machine in those few seconds.
The same recipe applies to any ephemeral-container platform (Kubernetes, Fly.io, Container Apps):
local-disk DB + abs:///s3:// replica + single writer + restore-on-boot.
Litestream is continuous streaming replication to object storage. Sometimes you want the other kind of
backup: a periodic, consistent full copy you can grab without a sidecar or object storage — "give me
last night's database." The Rask.SQLite.Snapshots package does exactly that, using SQLite's
Online Backup API (never an unsafe File.Copy of a live WAL database):
dotnet add package Rask.SQLite.Snapshotsbuilder.Services.AddRaskSqliteSnapshots(o =>
{
o.DatabasePath = "/data/app.db";
o.DestinationDirectory = "/backups";
o.Interval = TimeSpan.FromHours(6);
o.Retain = 14; // keep the 14 newest, prune the rest
o.SnapshotOnStartup = true; // also snapshot at boot
});Each snapshot is a complete standalone database (app-20260714-030000000.db). Need one on demand — say,
right before a risky migration? Inject ISqliteSnapshotter and await snapshotter.SnapshotAsync(ct).
To send snapshots to object storage instead of a local directory, register your own
ISqliteSnapshotStore before AddRaskSqliteSnapshots (then DestinationDirectory isn't required).
To show what you've actually captured, await store.ListAsync(ct) returns each snapshot's name, size and
timestamp, newest first — scoped to the same search pattern retention prunes by, so what you can see is what
the store manages. A custom store inherits a default that returns an empty list, so override it if yours can
enumerate: callers can't tell "no snapshots yet" from "this store doesn't list".
Snapshots vs Litestream — use one, or both: Litestream is continuous (near-zero data loss, point-in-time restore) but needs object storage and a sidecar binary; snapshots are periodic, self-contained, need nothing external, and are trivial to copy or archive. They complement each other — streaming for DR, plus a retained snapshot history for "restore last Tuesday."
All three packages run in a container — with the same care the platform sections above call for:
- Put the database on a writable local layer or a named volume, never a network mount. The container's own filesystem, a Docker named volume, or a bind mount to local disk all give WAL the real file locking it needs. An NFS/CIFS-backed volume does not — keep the DB off those.
- Mount a volume for snapshots (
DestinationDirectory) — otherwise the backups live in the container's ephemeral layer and vanish ondocker rm. Or register an object-storageISqliteSnapshotStoreand skip the volume.Rask.SQLite+Rask.SQLite.Snapshotsneed no extra binary, so they work on minimal/distroless .NET images (the bundlede_sqlite3native lib covers Debian/Ubuntu and Alpine/musl). - Litestream needs its binary in the image.
COPYit into your Dockerfile (or copy from the officiallitestream/litestreamimage in a multi-stage build) and setExecutablePathif it isn't onPATH. Restore-on-boot then rehydrates the DB when a fresh container starts. - Single writer. Don't scale the service to multiple replicas writing the same database
(
docker compose --scale, K8sreplicas > 1) — run one instance.
A Rask.Native app runs your C# natively on the device, so it can talk to SQLite
directly — and Rask.SQLite's pragmas (WAL, foreign_keys, busy_timeout) all apply on the device's
real sandbox filesystem. Two things make the base Rask.SQLite package the right choice here:
- Reflection-free → AOT-safe. iOS device builds are fully ahead-of-time compiled, and EF Core's
Expression.Compilecrashes there unless you force the Mono interpreter. The rawAddRaskSqlitepath uses onlyMicrosoft.Data.Sqlite— noExpression.Compile, no reflection — so it just works. - Lean. No Entity Framework Core in the app bundle. (If you do want EF Core on device, add
Rask.SQLite.EntityFrameworkCoreand set<MtouchInterpreter>-all</MtouchInterpreter>for iOS.)
Register it in the platform head, pointing the database at the app sandbox, before RunLocalAsync:
// Platforms/iOS/AppDelegate.cs or Platforms/Android/MainActivity.cs
var dbPath = Path.Combine(
System.Environment.GetFolderPath(System.Environment.SpecialFolder.LocalApplicationData),
"app.db");
host.Services.AddRaskSqlite($"Data Source={dbPath}");
host.Services.AddSingleton<IMyStore, SqliteMyStore>(); // your data service over IRaskSqliteConnectionFactory
_app = await host.RunLocalAsync<MyApp>(webView);The runnable reference is samples/Rask.Example.Native, whose Todos tab is backed by a
SqliteTodoStore on device: the same shared page uses a transient in-memory store on Server/WASM and
the SQLite store on mobile, so adding a todo, killing the app, and relaunching shows it persisted.
Backup on mobile: use a snapshot (SQLite's Online Backup API) into the sandbox or shared storage — not Litestream, which spawns a child process (impossible on iOS). WAL still works on-device;
Environment.SpecialFolder.LocalApplicationDatamaps to the app sandbox (iOSLibrary/, AndroidfilesDir), which is local storage, so the locking WAL needs is fine.
SQLite runs in the browser — Microsoft.Data.Sqlite and EF Core on top of it, in the tab, with no server
involved. What does not belong there is Rask.SQLite itself: that package is a server- and
mobile-side one, and deliberately so. Its whole value is the production pragma set — WAL,
busy_timeout, synchronous — which tames concurrent access to a file database. A browser app has
one connection and no concurrency to tame, so those pragmas buy it nothing.
Two things that used to make this impractical no longer do. Linking the native e_sqlite3 takes no work:
the patched SQLitePCLRaw bundle this repo already pins resolves to a native package that ships the
browser-wasm asset and adds the NativeFileReference itself. And the database living in memory — lost
on every reload — is what Rask.SQLite.Browser exists
to fix.
For client-side storage in a Rask WASM app, reach for what the browser already gives you and Rask already wraps:
- Key/value —
IBrowserStorage(localStorage/sessionStorage, ~5 MB) orIIndexedDb(hundreds of MB, async), both inRask.Core.Browser. See browser-apis.md. - A real client-side SQL database, in C# —
Rask.SQLite.Browser, below. SameMicrosoft.Data.Sqlite, same EF Core, persisted across reloads. - A real client-side SQL database, in JavaScript — the
sqlite-wasm+ OPFS stack in a Web Worker. A different engine, purpose-built for the browser, and still the only way to get SQLite reading and writing OPFS pages directly (which needs a Worker — see Where OPFS fits). Reach for it if the client-side database is the point of the app rather than a local cache of one.
For a server app, none of this changes the advice: keep SQLite behind the server (or on device with
Rask.Native) and let the client talk to it through an API. The browser database is for apps that must
work offline or own their data locally — not a way to avoid having a server.
It does run, though: the playground's tutorial does exactly this. Its data chapters run EF Core +
Microsoft.Data.Sqlitein the browser — native relink and all — and everything above about pragmas still holds, but "it can't work" would be too strong. Two constraints if you try it: the app must be untrimmed (PublishTrimmed=truebreaks EF Core, though raw ADO.NET survives it), and it needsNoWarn=WASM0001for the varargssqlite3_confignatives, which this repo's warnings-as-errors would otherwise turn into a failed build.What the playground does not solve is durability: its databases live in the runtime's in-memory filesystem and are gone on reload, which is the right trade for a teaching sandbox and the wrong one for an app.
Rask.SQLite.Browseris the answer to that, and it ships today.
The in-memory filesystem is the whole problem, and it is solvable without changing where the database
lives. Rask.SQLite.Browser restores the file from IndexedDB before anything opens it, writes it back on
an interval and on page-hide through SQLite's Online Backup API (never a file copy of a database something
might be writing), and elects a single owner tab with a Web Lock:
builder.Services.AddRaskBrowserSqlite("app");
builder.Services.AddDbContextFactory<AppDbContext>(o => o.UseSqlite(BrowserSqlite.ConnectionString("app")));Everything above that line — including AddRaskJobs<AppDbContext>() — is then the same code you
would write on a server. A worked example, with background jobs running against it and an E2E test that
queues a job and reloads the page: samples/Rask.Example.Wasm.Jobs.
Three limits, stated plainly because each one is a silent failure rather than an error:
-
The durability window is the snapshot interval, not the page-hide flush. The browser does not wait for a
pagehidehandler, so a force-closed or crashed tab loses whatever changed since the last tick. Shorten the interval if that matters; each tick copies the whole database, so the cost scales with its size rather than with how much changed. -
Snapshots live in IndexedDB, and IndexedDB is evictable. Under storage pressure a browser may discard them, and the database would come back empty on the next load with nothing to indicate why. The owning tab therefore asks for the origin to be exempted (
navigator.storage.persist()) at startup, and logs a refusal rather than failing. Chromium decides from engagement heuristics without prompting; Firefox prompts, and this is asked during boot rather than from a click — so an app that would rather pick its moment setso.RequestPersistentStorage = falseand callsIStorageEstimator.RequestPersistAsync()from a user-gesture handler instead. -
One tab owns the database. Every tab has its own copy of the in-memory filesystem, so two owners would mean two divergent databases and a last-writer-wins overwrite. The others run with their own empty, unpersisted database — which, left unexplained, looks exactly like the user's data having been deleted. Inject
BrowserSqliteOwnershipand say so:protected override async Task OnMountAsync() => _isOwner = await ownership.Resolved; // ownership.IsOwner is null while the election is in flight, so "deciding" and // "not the owner" stay distinguishable and the banner never flashes during a normal boot.
When the owner closes,
await ownership.Availablecompletes in the waiting tab so you can offer a reload. Reloading is what takes it over — a waiting tab already opened its own empty database at boot, so the file cannot be swapped under its live connections, and a tab that started persisting its empty database would overwrite the previous owner's good snapshot. Proxying a non-owner's writes to the owner is not implemented. -
The two build settings above are not optional:
PublishTrimmed=false, and publishing without-p:WasmBuildNative=false— otherwise SQLite is not linked in and the app boots normally, then fails on every database call.
Use journal_mode=DELETE rather than WAL here. That is not a WAL workaround: a browser database has one
connection and no reader-while-writer concurrency to protect, and DELETE leaves the file consistent after
every commit — which is exactly what makes its bytes safe to snapshot and ship somewhere else.
The Origin Private File System will make the flush incremental:
because it takes ranged writes, a snapshot can write only the 4 KB ranges that changed instead of the whole
file, which is what makes a short interval affordable for a large database. It is a pluggable backend
behind the same AddRaskBrowserSqlite call, not a different API. What it will not do any time soon is
move the live database onto OPFS: that needs createSyncAccessHandle, which exists only inside a Worker,
and Rask boots the .NET runtime on the main thread. So the database stays in the in-memory filesystem, and
restore-at-boot stays a rehydrate — from a different source, and a faster flush.
SQLite is a genuine production database for a single-server app — but it isn't a client-server database,
and being honest about the boundaries is part of the pitch. Each of these is demonstrated by a test in
tests/Rask.SQLite.Limitations.Tests so the behavior is pinned,
not just asserted in prose.
- One writer at a time. A write transaction holds the database's single write lock; WAL lets readers
keep reading during a write, but writes serialize. That's plenty for the vast majority of apps — but a
very write-heavy or highly-concurrent-write workload eventually hits this ceiling. A second
BEGIN IMMEDIATEwhile another is open getsSQLITE_BUSY(which the transaction helpers above wait out); there is noSELECT … FOR UPDATE SKIP LOCKED. - One machine. The database is a local file — you cannot scale writes across servers. Litestream is continuous backup / disaster-recovery + read-replica restore, not multi-primary replication. Needing several app servers that all write, or managed HA failover, is the boundary.
- Local disk only. Never put the database on a network filesystem (NFS/CIFS/SMB) — SQLite's file locking is unreliable there and can corrupt the file, and WAL needs real shared memory. Use local disk or a single-attach block/named volume, one writer process.
- Dynamic typing. SQLite uses type affinity, not strict types: it will happily store the text
"lots"in anINTEGERcolumn. EF Core's model gives you type safety in C#, but the store itself won't enforce it if something writes to it directly. - No native
decimalorDateTimeOffset. Storage classes areINTEGER/REAL/TEXT/BLOB/NULLonly. To preserve precision, EF Core stores adecimalasTEXT(notREAL, which would round —0.1 + 0.2 ≠ 0.3). The value round-trips exactly, but a TEXT column doesn't sort or aggregate numerically in SQL, so server-sideORDER BY/Sum/Averageon a decimal is unreliable (EF Core warns). For money, store integer minor units instead (the EF Core sample'sMoneyvalue object stores cents) — it sorts and sums correctly. EF Core likewise mapsDateTime/DateOnly/TimeOnly/GuidtoTEXT— mind text-sort ordering. - Limited
ALTER TABLE. SQLite can add/rename/drop columns but not, say, change a column's type or constraints in place — EF Core migrations rebuild the table (create → copy → drop → rename) for those, which is slower and briefly locks the table. - No server-side surface. It's an in-process library, not a server: no network endpoint, no
users/roles/
GRANT, no stored procedures, noLISTEN/NOTIFY. Access control and connection management are the app's job.
Outgrowing it is cheap. Moving to a client-server database when you genuinely need multi-writer
scale-out or managed HA is a provider + connection-string change: rask new --database postgres scaffolds
it, and the Rask.Data aggregates, Rask.Cqrs handlers and generated slices are
provider-agnostic either way. What you leave behind is everything on this page that treats the database as
a file — Litestream, snapshots, rask db backup. See Choosing a database for the
full trade, including the multi-instance rules. SQLite gets you far enough that most single-person products
never make the switch.
The pragmas are easy to assert against a real database file — open a connection through the factory or the EF interceptor and read them back:
using var cmd = connection.CreateCommand();
cmd.CommandText = "PRAGMA journal_mode;";
Assert.Equal("wal", cmd.ExecuteScalar());See tests/Rask.SQLite.Tests for the unit + integration coverage, and
tests/Rask.Examples.E2E.Tests/SqliteExampleTests.cs for the end-to-end concurrent-writes check.
Careful with
SqliteConnection.ClearAllPools(). It is process-global and disposes the underlyingsqlite3handle of connections that are currently leased and in use, not just idle ones — so calling it while writes are in flight can throwObjectDisposedExceptionfrom a live connection on another thread. In tests, keep pool-clearing teardown from running in parallel with connection-using tests (the SQLite test assemblies set[assembly: CollectionBehavior(DisableTestParallelization = true)]for this); in app code, do not call it on a reset/health-check path that overlaps request handling.
For load rather than correctness, benchmarks/Rask.Benchmarks.Sqlite drives sustained concurrent traffic and
reports throughput, tail latency and error counts (the numbers in
Load-test numbers above). Its check mode is a regression gate over invariants and
same-run ratios — never absolute milliseconds, which are unusable on shared hardware:
scripts/run-sqlite-load-local.sh # the gate; run it for any change under src/Rask.SQLite*dotnet run --project samples/Rask.Example.Sqlite
# then open the printed URL — the page shows the live pragma values and a concurrent-writes demo