iOS SQLite Crash + Android 16 KB XA0141 Fix in .NET MAUI
An iOS crash (BUG IN CLIENT OF libsqlite3.dylib) and Android's XA0141 16 KB page-size warning share one root fix. Complete .NET MAUI SQLite guide.
Introduction
Two production problems from the same .NET MAUI codebase, with the same underlying cause and a lot of overlapping cure:
- An iOS crash —
EXC_BREAKPOINT: BUG IN CLIENT OF libsqlite3.dylib: illegal multi-threaded access to database connection— that aborts the process a few seconds after launch. - Android build warning
XA0141—shared library 'libe_sqlite3.so' does not have a 16 KB page size— a compatibility time bomb for Android 15/16 devices.
Both come back to the same thing: your app is running on the wrong SQLite native library, and it is closing/replacing the connection without discipline. This post covers the root cause of each, the fix that solves both at once (move to SQLitePCLRaw.bundle_e_sqlite3), the concurrency discipline the iOS crash also needs, and a complete checklist for setting up SQLite in .NET MAUI the right way. Every reference I used is linked at the end.
If you use sqlite-net-pcl (or EF Core SQLite) in a MAUI app that does background sync, backups, or "reset local data", this affects you.
Problem 1: The iOS "illegal multi-threaded access" crash
The message comes straight from Apple's build of SQLite. libsqlite3.dylib on iOS ships with a watchdog: the moment it detects that two threads are inside the same sqlite3* connection handle at the same time, it does not serialize them and it does not return an error — it calls abort(). Your process dies with EXC_BREAKPOINT, usually with sqlite3_finalize on the crashed thread.
Two facts make this easy to trigger by accident:
1. SQLITE_OPEN_FULLMUTEX does not save you. In portable SQLite, "serialized" threading mode means concurrent calls block on a mutex. Apple's build instead treats concurrent entry as a client bug and aborts. Apple's own Developer Forums thread (external) spells this out: do not rely on SQLite mutexes for concurrent access — use one connection per thread, or serialize yourself.
2. sqlite-net-pcl protects operation‑vs‑operation, but not CloseAsync(). A single SQLiteAsyncConnection serializes its own queries with an internal lock. But SQLiteAsyncConnection.CloseAsync() goes through the connection‑pool path, which does not take that lock. So if any thread calls CloseAsync() while another thread is mid‑sqlite3_step, you get exactly this crash — closing finalizes every cached prepared statement, which is why sqlite3_finalize is the frame the OS aborts on.
In our code the trigger was a "wipe local data and re‑sync" flow:
// Runs right after login, while the parallel sync is already inserting rows
await Database.CloseDatabaseAsync();
if (File.Exists(Database.PathDB))
File.Delete(Database.PathDB);
await Database.ReopenDatabaseAsync();
CloseDatabaseAsync() closed the native handle while eight SyncService worker tasks were still writing. Boom.
Problem 2: Android 16 KB page sizes and warning XA0141
If you build for modern Android, your logs probably already show this:
warning XA0141: Android 16 will require 16 KB page sizes, shared library
'libe_sqlite3.so' does not have a 16 KB page size.
Why it happens: Android is moving from 4 KB to 16 KB memory pages. Devices like the Pixel 8/9 (and the 16 KB emulator image) enforce it. Any native .so in your app that was linked with 4 KB segment alignment can fail to load or crash on those devices — and starting with Android 16 (API 36) targeting, Google requires 16 KB‑aligned native code. XA0141 is the .NET for Android build check that flags the offenders.
Why SQLite is on the list: libe_sqlite3.so is the native C library behind your database. For years the standard advice was SQLitePCLRaw.bundle_green, whose older bundled binaries — and the system SQLite on many devices — were built with 4 KB alignment. SQLitePCLRaw re-aligned its native builds in 2.1.10, and the 3.x line is aligned throughout.
The fix: switch the provider to SQLitePCLRaw.bundle_e_sqlite3 (2.1.10+ or, better, 3.x). That is the entire fix for XA0141 on the SQLite side — and it happens to be the same move that gets iOS off Apple's watchdog. One package change, two problems gone. I wrote this up separately in SQLite vs. Android 16 KB Page Sizes: Fixing XA0141 — this post folds it into the bigger picture.
The shared root cause
Both problems are "you are using the wrong SQLite binary":
| iOS | Android | |
|---|---|---|
| Wrong binary | Apple's system libsqlite3.dylib (has the abort watchdog) |
old 4 KB‑aligned libe_sqlite3.so |
| Symptom | EXC_BREAKPOINT abort on concurrent entry |
XA0141 warning, load failure / crash on 16 KB devices |
| Fix | SQLitePCLRaw.bundle_e_sqlite3 (vendored, no watchdog) |
SQLitePCLRaw.bundle_e_sqlite3 2.1.10+ (16 KB aligned) |
One caveat that makes people think they have already fixed it: sqlite-net-pcl still binds Apple's SQLite on iOS by default. Check the .nuspec of the current sqlite-net-pcl (1.11.285 as I write this): the net8.0-ios18.0 dependency group pulls SQLitePCLRaw.provider.sqlite3 — the system provider. So bumping the package version alone changes nothing on iOS; you need the explicit bundle_e_sqlite3 reference. And SQLitePCLRaw.bundle_green does not exist for SQLitePCLRaw 3.x at all.
The other caveat, for iOS specifically: even with a vendored engine, closing a connection under an active query is undefined behavior. You trade a clean abort() for SQLITE_MISUSE, a use‑after‑free, or silent corruption. So the iOS crash needs both the engine swap and a concurrency discipline.
The Solution, Part A: switch the SQLite engine (fixes XA0141, removes the iOS watchdog)
<ItemGroup>
<PackageReference Include="sqlite-net-pcl" Version="1.11.285" />
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="3.0.5" />
</ItemGroup>
Remove SQLitePCLRaw.bundle_green, any SQLitePCLRaw.provider.dynamic_*, and any manual SQLitePCLRaw.lib.e_sqlite3.* pin. Then be explicit about initialization in MauiProgram:
public static MauiApp CreateMauiApp()
{
SQLitePCL.Batteries_V2.Init(); // loads the bundled, aligned, watchdog-free e_sqlite3
var builder = MauiApp.CreateBuilder();
// ...
}
Verify:
- Android: clean + rebuild —
XA0141is gone. Run on a 16 KB emulator image or a Pixel 8/9 with the 16 KB developer option; database operations work. - iOS: on a real device, run the flows that used to crash (login + sync, backup, restore). Also confirm locked‑device access still works if you rely on
ProtectionCompleteUntilFirstUserAuthentication— see the checklist below.
The Solution, Part B: serialize the connection lifecycle (the iOS crash needs this)
I made the database class the sole owner of the connection lifecycle and put a two‑mode gate in front of it:
- Shared lease — every read and write takes one. Leases do not block each other (the
sqlite-netinternal lock still serializes the actual native calls); they only block while a maintenance operation is pending or running. - Exclusive lease — close, delete, replace, and reopen take this. It stops new operations, waits for in‑flight ones to drain, and holds the gate for the entire maintenance cycle.
Step 1: The gate
private static readonly SemaphoreSlim _exclusiveGate = new(1, 1);
private static readonly object _operationSync = new();
private static int _activeOperations;
private static TaskCompletionSource<bool>? _operationsDrained;
// Lets Db.* calls made from *inside* an exclusive maintenance block skip the gate
// they would otherwise deadlock on.
private static readonly AsyncLocal<bool> _inExclusiveScope = new();
public static async Task<IDisposable> EnterOperationAsync(CancellationToken ct = default)
{
if (_inExclusiveScope.Value)
return NoOpLease.Instance;
// Pass through the exclusive gate only to register — do not hold it.
await _exclusiveGate.WaitAsync(ct).ConfigureAwait(false);
try
{
lock (_operationSync)
{
_activeOperations++;
}
}
finally
{
_exclusiveGate.Release();
}
return new OperationLease();
}
private static void ExitOperation()
{
lock (_operationSync)
{
_activeOperations--;
if (_activeOperations == 0)
_operationsDrained?.TrySetResult(true);
}
}
The exclusive side holds the semaphore for its whole lifetime, so no new operation can register while maintenance runs:
private static async Task<IDisposable> EnterExclusiveAsync(CancellationToken ct = default)
{
await _exclusiveGate.WaitAsync(ct).ConfigureAwait(false);
Task drained;
lock (_operationSync)
{
if (_activeOperations == 0)
return new ExclusiveLease();
_operationsDrained = new TaskCompletionSource<bool>(
TaskCreationOptions.RunContinuationsAsynchronously);
drained = _operationsDrained.Task;
}
try
{
// Bounded wait — a leaked lease must not freeze the database forever.
await drained.WaitAsync(TimeSpan.FromSeconds(30), ct).ConfigureAwait(false);
return new ExclusiveLease();
}
catch
{
lock (_operationSync)
{
_operationsDrained = null;
}
_exclusiveGate.Release();
throw;
}
}
Step 2: An atomic maintenance API
Every "close, touch files, reopen" site becomes a single call that holds the exclusive lease from start to finish. Crucially, they all treat the WAL sidecar files as part of the database:
public static IReadOnlyList<string> DatabaseFiles => new[]
{
PathDB,
$"{PathDB}-wal",
$"{PathDB}-shm",
$"{PathDB}-journal",
};
public static Task WipeAsync(CancellationToken ct = default) =>
ExecuteExclusiveAsync(async () =>
{
await CloseInternalAsync().ConfigureAwait(false);
DeleteDatabaseFilesInternal(); // deletes .db3 + -wal + -shm + -journal
await ReopenInternalAsync().ConfigureAwait(false);
}, ct);
public static Task ReplaceWithAsync(string sourceDbPath, CancellationToken ct = default) =>
ExecuteExclusiveAsync(async () =>
{
await CloseInternalAsync().ConfigureAwait(false);
DeleteDatabaseFilesInternal();
File.Copy(sourceDbPath, PathDB, overwrite: true);
await ReopenInternalAsync().ConfigureAwait(false);
}, ct);
public static Task WithClosedDatabaseAsync(Func<Task> whileClosed, CancellationToken ct = default) =>
ExecuteExclusiveAsync(async () =>
{
await CloseInternalAsync().ConfigureAwait(false);
try
{
await whileClosed().ConfigureAwait(false); // zip / copy the file, etc.
}
finally
{
await ReopenInternalAsync().ConfigureAwait(false);
}
}, ct);
Deleting only the
.db3is a data‑corruption bug. With WAL enabled, the real database is.db3plus-walplus-shm. Delete the main file alone and, on the next open, SQLite can replay a stale write‑ahead log over your fresh database. Always delete — or back up — the set.
Step 3: Close the Table<T>() hole
sqlite-net's AsyncTableQuery<T> is lazy: the terminal call (ToListAsync, FirstOrDefaultAsync, CountAsync) executes later, outside any lease. We had ~40 of these per app. Rather than touch every call site, Db.Table<T>() now returns a thin wrapper whose composition operators are pure and whose terminals take a lease and resolve the connection at execution time:
public sealed class GatedTableQuery<T> where T : new()
{
private readonly Func<AsyncTableQuery<T>, AsyncTableQuery<T>> _build;
public GatedTableQuery<T> Where(Expression<Func<T, bool>> p) => Chain(q => q.Where(p));
public GatedTableQuery<T> OrderBy<TV>(Expression<Func<T, TV>> e) => Chain(q => q.OrderBy(e));
// Take / Skip / OrderByDescending / ThenBy ...
public Task<List<T>> ToListAsync() => ExecuteAsync(q => q.ToListAsync());
public Task<T> FirstOrDefaultAsync() => ExecuteAsync(q => q.FirstOrDefaultAsync());
public Task<int> CountAsync() => ExecuteAsync(q => q.CountAsync());
private async Task<TResult> ExecuteAsync<TResult>(
Func<AsyncTableQuery<T>, Task<TResult>> terminal)
{
using var _ = await Database.EnterOperationAsync().ConfigureAwait(false);
var conn = await Database.GetReadConnectionAsync().ConfigureAwait(false);
return await terminal(_build(conn.Table<T>())).ConfigureAwait(false);
}
}
Step 4: Cancel the producer before wiping
A gate only drains what is already running. If a background sync keeps enqueuing inserts, the exclusive lease can wait a long time — and worse, those queued writes land in the new database with old-session data. So the wipe flow now cancels sync first:
// Inside "reset local data"
ServiceHelper.GetRequiredService<SyncService>().Cancel(); // signals its CancellationTokenSource
foreach (var key in _preferenceKeysToClear)
Preferences.Remove(key);
await Database.WipeAsync(); // atomic: close + delete set + reopen
await ManutencaoTabelas.CriaOuAtualizaTabelas(); // recreate schema
Complete SQLite Setup for .NET MAUI (the 100% checklist)
Here is the setup I would use on a new MAUI app today, and what each piece buys you.
1. Packages
<ItemGroup>
<PackageReference Include="sqlite-net-pcl" Version="1.11.285" />
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="3.0.5" />
</ItemGroup>
sqlite-net-pcl— the ORM by Frank Krueger (praeclarum). This is the package with[PrimaryKey],SQLiteAsyncConnection,Table<T>().SQLitePCLRaw.bundle_e_sqlite3— ships a vendored, 16 KB‑aligned SQLite build on every platform, including iOS and macOS. Takes you off Apple'slibsqlite3.dylib(and its abort watchdog) and clears Android'sXA0141.
Do not use:
SQLitePCLRaw.bundle_green— system SQLite on Apple (the watchdog), older 4 KB‑aligned natives elsewhere, and not published for SQLitePCLRaw 3.x.SQLitePCLRaw.provider.dynamic_*— another source of the misaligned/incompatible binary.Microsoft.Data.Sqliteandsqlite-net-pcltogether — two ORMs, two providers, conflicts. Pick one.- A manual
SQLitePCLRaw.lib.e_sqlite3.*pin — let the bundle resolve the native libs.
2. Initialize the provider once
sqlite-net-pcl calls Batteries_V2.Init() for you, but with an explicit bundle it is good practice to be deterministic in MauiProgram:
public static MauiApp CreateMauiApp()
{
SQLitePCL.Batteries_V2.Init();
var builder = MauiApp.CreateBuilder();
// ...
}
3. One connection, created once, kept forever
SQLiteAsyncConnection is not safe to instantiate multiple times against the same file. Use a lazy singleton and register it (or its owning service) as a singleton in DI. Never new a connection per page or per repository call — an orphaned connection collected by the GC finalizer thread is another way to hit "illegal multi-threaded access".
4. Connection flags
const SQLiteOpenFlags Flags =
SQLiteOpenFlags.ReadWrite |
SQLiteOpenFlags.Create |
SQLiteOpenFlags.FullMutex | // serialized mode — belt, not braces
SQLiteOpenFlags.ProtectionCompleteUntilFirstUserAuthentication;
_connection = new SQLiteAsyncConnection(DbPath, Flags);
FullMutexis still worth setting on the vendored engine — it is a real serialized-mode guard. It is not a substitute for your own maintenance discipline.ProtectionCompleteUntilFirstUserAuthenticationkeeps the.db3/-wal/-shmreadable when the device is locked after the first unlock — required for background work on iOS, and the cure for randomdisk I/O errorright after resume. No-op on Android. This flag is an Apple Data Protection feature; after moving to a fully vendored engine, verify locked‑device access on a real device.
5. Database path
public static string DbPath =>
Path.Combine(FileSystem.AppDataDirectory, "app.db3");
Use FileSystem.AppDataDirectory — never Environment.GetFolderPath(...). On iOS, AppDataDirectory is backed up to iCloud; if the DB is large or purely a cache, exclude it from backup (NSURLIsExcludedFromBackupKey) or place it under FileSystem.CacheDirectory.
6. Enable WAL exactly once
journal_mode = WAL is persisted in the file header. Setting it on every launch is what causes disk I/O error on the first query after resume on a freshly unlocked device. Gate it with a preference:
if (!Preferences.Get("db_wal_enabled", false))
{
try
{
await _connection.EnableWriteAheadLoggingAsync();
Preferences.Set("db_wal_enabled", true);
}
catch (SQLiteException ex)
{
// Non-fatal: falls back to rollback journal. Retry next launch.
Debug.WriteLine($"[DB] WAL deferred: {ex.Message}");
}
}
7. busy_timeout every launch
Per-connection, not persisted — set it each time you open:
await _connection.ExecuteAsync("PRAGMA busy_timeout=5000;");
Gives the connection up to 5 seconds to wait out a transient lock instead of throwing SQLITE_BUSY immediately. Recommended for any multi-threaded WAL setup.
8. Performance basics
- Wrap batch writes in
RunInTransactionAsync— hundreds of individualInsertAsynccalls are painfully slow. - Add
[Indexed]to foreign keys and any column you filter on. - Prefer raw
QueryAsync<T>for joins; chained LINQ generates worse SQL. - Don't
ToListAsync()a large table into memory — filter and page.
Tips
- Treat close/reopen as a privileged operation. If more than one place in your codebase calls
CloseAsync(), wrap them all behind one gated API. Ad-hocClose(); File.Delete(); Reopen();is a race waiting to happen. - Read everything you need before you close. Inside a "database is closed" block, any stray
Db.*call silently reopens the connection and your file copy captures a live database. - Checkpoint before backup.
PRAGMA wal_checkpoint(TRUNCATE);folds the WAL into the main file so your backup only needs the.db3. Shipping a-walinside a zip is how restores get corrupted. - Cancel background producers before maintenance, then run maintenance, then restart them.
- Keep a static reference to the connection. A GC-finalized
SQLiteConnectionrunssqlite3_closeon the finalizer thread — concurrent with whatever else is running. ConfigureAwait(false)on data-layer awaits.sqlite-net'sTask.RuncapturesTaskScheduler.Current; resuming on the UI thread is a classic iOS deadlock.- Grep your
.csprojfiles across every app you own. Ours had drifted: one app still onbundle_green+sqlite-net-pcl1.9.x, two already modern. Standardize them.
Conclusion
Two headline lessons. On Android, ship the modern 16 KB‑aligned SQLite native or Android 15/16 will refuse to load it — XA0141 is your early warning. On iOS, SQLite is single-entry per connection, enforced by abort(); a single SQLiteAsyncConnection handles query‑vs‑query for you, but the moment your app closes or replaces that connection — logout, backup, restore, "reset local data" — you are on your own.
The one move that helps both: reference SQLitePCLRaw.bundle_e_sqlite3 explicitly and drop bundle_green. Then, for iOS, put a gate in front of the connection lifecycle, make maintenance atomic, treat .db3 + -wal + -shm as one unit, and cancel your background workers before you wipe.
If your MAUI app has ever thrown disk I/O error on resume, the Android 15 edge-to-edge and localization (resx-lint) posts cover other production-only failure modes from the same codebase. Questions or a war story of your own? Get in touch.
References
Primary sources I used while diagnosing and fixing this:
- Apple Developer Forums — "Handling race conditions with SQLite" (thread 667833) — Apple engineer explains why the system
libsqlite3.dylibaborts on concurrent connection use and whyFULLMUTEXis not the answer. - SQLite — "Using SQLite In Multi-Threaded Applications" — the canonical threading-mode reference (
SQLITE_THREADSAFE, single-thread / multi-thread / serialized). - praeclarum/sqlite-net — issue #991: "Throwing exception in iOS14 for sqlite-net-pcl" — community discussion of iOS-specific SQLite failures with
sqlite-net-pcl. - groue/GRDB.swift — issue #657: "BUG IN CLIENT OF libsqlite3.dylib: illegal multi-threaded access" — the exact same abort from the Swift side; useful confirmation of the mechanism.
- ccgus/fmdb — issue #724: same
illegal multi-threaded accesslog — another cross-language datapoint. - Keith Beatty — "SQLite-net-pcl multi-threading has problems on Xamarin; change to use single connection" — the "one shared connection" pattern for Xamarin/MAUI.
- NuGet —
sqlite-net-pcl— I read the.nuspecdependency groups; thenet8.0-ios18.0group pullsSQLitePCLRaw.provider.sqlite3(system SQLite), notprovider.e_sqlite3. - NuGet —
SQLitePCLRaw.bundle_e_sqlite3andSQLitePCLRaw.bundle_green— version history;bundle_greenstops at 2.x,bundle_e_sqlite3continues into 3.x. - ericsink/SQLitePCL.raw — the provider/bundle project; explains what each
bundle_*maps to per platform. - Android Developers — "Support 16 KB page sizes" — the platform change behind
XA0141. - .NET for Android — build message
XA0141— the warning's official description. - My earlier write-up: SQLite vs. Android 16 KB Page Sizes: Fixing Warning XA0141 in .NET MAUI.
Comments
Have a question, or found an issue with the code? Drop a comment below — I read and reply to every one.