Room 3 froze on a suspending @Transaction: the SQLite driver you pass picks your connection pool
Short answer: Room 3 does not manage connections the same way for every driver. It reads the hasConnectionPool property off whatever SQLiteDriver you pass to setDriver(), and that single boolean picks the concurrency model. AndroidSQLiteDriver returns true, so Room wraps it in PassthroughConnectionPool, which holds one thread-confined connection and has no acquisition timeout at all. A suspending @Transaction DAO function on that pool can block forever: the main thread stays idle, so there is no ANR, no crash, and no log line. Swapping to BundledSQLiteDriver makes Room run its own pool instead, and Room configures PRAGMA synchronous on every connection it opens, which quietly discards anything you set in RoomDatabase.Callback.onOpen.
TL;DR
- Fix: pass a driver that reports
hasConnectionPool = false(BundledSQLiteDriverdoes, by inheriting the default) so Room manages its own pool. On WAL that is four readers and one writer, and it has a documented acquisition timeout (30 seconds by default) that throwsSQLiteExceptioninstead of waiting. - Diagnosis:
kill -3on the process, then read/data/anr/trace_00. SIGQUIT dumps every thread without needing a crash, an ANR dialog, or a debugger attach. - Trap: the driver swap moves you from a single connection to several, and Room runs its per-connection PRAGMA setup on each of them.
RoomDatabase.Callback.onOpenfires once per database, not once per connection, so a pragma you set there survives on exactly one connection. - Second trap: if you write a driver wrapper to fix that, delegate
inTransaction(). The default implementation inandroidx.sqlitethrowsNotImplementedError, and Room calls it before every write transaction.
Why does an Android app hang on the splash screen with no crash and no ANR?
Because the main thread is not stuck. Android raises an ANR when the main thread fails to service input or draw, and a main thread parked in epoll_pwait waiting for the next message is, as far as the system is concerned, perfectly healthy. If your splash gate is a boolean that some background work is supposed to flip, and that background work is blocked, the main thread will happily keep drawing the splash forever.
This is what I was looking at one morning in early autumn: reinstall the app with adb install -r, cold start, splash, nothing. Three cold starts, three hangs, every time. The process was alive, dumpsys activity activities showed it as topResumedActivity, and the splash condition came from the standard pattern:
| |
I spent the first stretch of that session chasing the wrong things, and all three dead ends were my own.
Dead end one: looking for a crash that was not there. My first instinct was that something had thrown on a background thread and been swallowed.
$ adb logcat -d --pid=$(adb shell pidof com.example.tracker) | grep -c "FATAL\|AndroidRuntime"
0
Zero. There was no exception. A deadlock produces no log output at all, which is exactly what makes it expensive: every tool that reports failures had nothing to report.
Dead end two: blaming the network. The app opens a server-sent-events stream on startup, so I assumed the stream had stalled and the readiness flag was waiting on it. The OkHttp log said otherwise: the requests went out and came back with 200 OK, including the long-lived stream. The network layer had done its job and gone quiet.
Dead end three: blaming the splash API. I read setKeepOnScreenCondition three times looking for a lifecycle mistake. There was none. The condition was correct; the value it read never changed.
The lesson I keep relearning: when there is no error, stop reading your own code and ask the runtime what the threads are doing.
How do you get a stack trace out of a process that has not crashed?
Send it SIGQUIT. The Android runtime installs a signal handler that dumps every thread’s stack to a trace file, which is the same mechanism that produces an ANR trace, except you get to trigger it on demand:
$ adb root
$ adb shell kill -3 $(adb shell pidof com.example.tracker)
$ adb shell cat /data/anr/trace_00
That dump changed the problem from “the app hangs” to “I know which lock”. The main thread was where I expected it:
"main" prio=5 tid=1 Native
native: #00 pc ... /apex/com.android.runtime/lib64/bionic/libc.so (__epoll_pwait+8)
at android.os.MessageQueue.nativePollOnce(Native method)
at android.os.Looper.loopOnce(Looper.java:...)
Idle. The interesting thread was a coroutine worker. Trimmed, and with my own class and package names replaced by generic ones:
"DefaultDispatcher-worker-2" prio=5 tid=24 Waiting
at java.util.concurrent.locks.ReentrantLock.lock(ReentrantLock.java:...)
at androidx.room3.TriggerBasedInvalidationTracker$syncTriggers$2$1.invokeSuspend(InvalidationTracker.kt:634)
at androidx.room3.coroutines.PassthroughConnectionPool$useConnection$3.invokeSuspend(PassthroughConnectionPool.kt:90)
...
at androidx.room3.util.DBUtil__DBUtilKt.performInTransactionSuspending(DBUtil.kt:102)
at com.example.tracker.data.OutboxDao_Impl.takeNextForUpload(OutboxDao_Impl.kt:192)
Three names in that stack told the whole story: PassthroughConnectionPool, TriggerBasedInvalidationTracker, and performInTransactionSuspending. A DAO function of mine, marked @Transaction and suspend, had entered Room’s transaction machinery and was waiting on a lock held by something that was waiting on it.
The DAO function was unremarkable. It claims the next queued item by moving it to an in-flight state inside a transaction, so that two concurrent workers cannot pick up the same row:
| |
A @Transaction function with a body, rather than a single query, is what the Room team calls a transaction delegate. Room generates a wrapper that opens a transaction and invokes your code inside it; the generated entry point is performInTransactionSuspending, whose own documentation says it “is needed to support @Transaction delegate DAO functions”. That distinction matters here, and I will come back to it.
Why does the SQLite driver decide whether Room can deadlock?
Because Room asks the driver whether it already has a pool, and takes the answer at face value. SQLiteDriver carries a hasConnectionPool property whose default is false, and the Android framework driver overrides it. From AndroidSQLiteDriver.android.kt:
| |
BundledSQLiteDriver does not override the property at all, so it inherits false. The consequence runs opposite to the intuition: the driver that advertises a connection pool is the one that leaves Room without a pool of its own. RoomConnectionManager.android.kt branches on it directly, and the comment names the driver:
| |
“Thread-confined” is the load-bearing word. The framework’s pool hands out a connection tied to the calling thread, and coroutines change threads whenever the dispatcher feels like it. PassthroughConnectionPool is candid about what it is:
| |
It holds a single private lateinit var connection: SQLiteConnection and finds reentrant access by looking for a ConnectionElement in the current coroutine context. Lose the thread, and you still have the context element; lose the context element, and you queue behind yourself.
AndroidSQLiteDriver | BundledSQLiteDriver | |
|---|---|---|
hasConnectionPool | true (overridden) | false (inherited default) |
| Pool Room installs | PassthroughConnectionPool | newSingleConnectionPool or newConnectionPool |
| Connections Room sees | one, thread-confined | one (in-memory) or four readers + one writer on WAL |
| Acquisition timeout | none | 30 s by default, then SQLiteException |
| Native code in your APK | none | libsqliteJni.so, four ABIs |
| SQLite version | whatever the OS ships | whatever the artifact ships |
The reader and writer counts are not folklore; they are constants in ConnectionPoolConfiguration.kt:
| |
Where exactly does a suspending @Transaction wait on itself?
In the invalidation tracker sync that Room runs immediately before opening a write transaction. DBUtil.kt acquires the connection first, then calls invalidationTracker.sync() inside that connection’s scope:
| |
sync() reaches TriggerBasedInvalidationTracker.syncTriggers(), which asks for a writer connection of its own (useConnection(isReadOnly = false)) and then takes a plain ReentrantLock guarding the observed-table state. That state is guarded by two different kinds of lock at once, and InvalidationTracker.kt documents the ordering rule in the source:
| |
A kotlinx.coroutines.sync.Mutex is owned by a coroutine. A ReentrantLock is owned by a thread. Holding one and then taking the other, in a code path that is allowed to change threads partway through, is the hazard, and it is the hazard the Room team named out loud. The commit behind the fix in 3.0.2, authored by Daniel Santiago Rivera on 7 August 2026, says it plainly:
A @Transaction delegate in a DAO has user-code that might switch dispatchers and cause databases with a passthrough pool (AndroidSQLiteDriver) to deadlock due to a change of thread. There an optimization to not hop to the transaction thread for ‘internal’ Room operations but that is only safe knowing no dispatcher change occurs, something that can’t be guaranteed with transaction delegates in DAOs.
The remedy in that commit is a context marker, and the comment it left in DBUtil.kt is now the second line of performInTransactionSuspending:
| |
In the current source that withContext sits at DBUtil.kt:102, which is the exact frame my thread dump pointed at.
The reason the symptom was an eternal splash rather than an error is the timeout column in the table above. PassthroughConnectionPool.useConnection takes no timeout parameter and has nowhere to put one. Room’s own pool does, and useReaderConnection documents the consequence: “If a caller has to wait too long to acquire a connection a SQLiteException will be thrown due to a timeout.” Room 3.1.0-alpha01 made the value settable, and its setConnectionPoolTimeout documentation puts the default at 30.seconds. Same class of bug, two completely different failure modes. One throws after half a minute and lands in your crash reporter. The other holds the only connection in the process, so every subsequent read queues behind it, the readiness flag never flips, and you get a splash screen that outlives your patience.
Is this already fixed in a newer Room 3?
Partly, and the version history is worth reading before you blame your own code. All three of these entries live in the Room 3 release notes:
| Version | Date | Relevant entry |
|---|---|---|
| 3.0.2 | 26 Aug 2026 | “Fix a deadlock that can occur on @Transaction DAO wrapper suspend functions that would do a context switch and had the AndroidSQLiteDriver configured.” (b/543285472) |
| 3.0.3 | 9 Sep 2026 | “Fix a regression that would cause Room to throw an IllegalMonitorStateException when using @Transaction wrapper DAO functions.” (b/543285472, b/553140228) |
| 3.1.0-alpha01 | 9 Sep 2026 | Adds setConnectionPoolTimeout(timeout: Duration) to RoomDatabase.Builder |
I was running 3.0.2 when this bit me, which is to say I was running the version whose notes claim to fix this exact configuration, and the DBUtil.kt:102 frame in my dump is the fix itself. Then 3.0.3 had to repair a regression introduced by that fix, under the same bug id. I draw one conclusion from that sequence, and it is not that Room is broken: a thread-confined connection behind a coroutine API is a hard problem that took several attempts to get right, and the configuration that needs those attempts is the passthrough pool. If you can avoid being on that code path at all, avoid it.
Which is why the first thing I changed was one line in the builder:
| |
The splash cleared on the next install. Upgrade to current Room as well, of course. But the driver choice is the one that moves you off the pool implementation that “doesn’t do any connection management”.
What does switching to BundledSQLiteDriver actually cost?
Native code in your package, and you can measure that from Google’s Maven repository without building anything. I pulled both artifacts at version 2.7.1 and read the archives:
$ curl -sO https://dl.google.com/android/maven2/androidx/sqlite/sqlite-framework-android/2.7.1/sqlite-framework-android-2.7.1.aar
$ curl -sO https://dl.google.com/android/maven2/androidx/sqlite/sqlite-bundled-android/2.7.1/sqlite-bundled-android-2.7.1.aar
$ stat -c '%10s %n' *.aar
2551019 sqlite-bundled-android-2.7.1.aar
60286 sqlite-framework-android-2.7.1.aar
60,286 bytes against 2,551,019. The difference is four copies of a compiled SQLite:
$ unzip -l sqlite-bundled-android-2.7.1.aar 'jni/*'
Length Date Time Name
--------- ---------- ----- ----
1281456 1980-02-01 00:00 jni/arm64-v8a/libsqliteJni.so
1254892 1980-02-01 00:00 jni/armeabi-v7a/libsqliteJni.so
1140764 1980-02-01 00:00 jni/x86/libsqliteJni.so
1189688 1980-02-01 00:00 jni/x86_64/libsqliteJni.so
--------- -------
4866800 9 files
Deflated inside the archive each one costs a little under half that:
| ABI | libsqliteJni.so uncompressed | stored in the AAR |
|---|---|---|
arm64-v8a | 1,281,456 B | 645,789 B |
armeabi-v7a | 1,254,892 B | 625,184 B |
x86_64 | 1,189,688 B | 626,525 B |
x86 | 1,140,764 B | 632,389 B |
A universal APK carries all four, roughly 2.5 MB compressed. An ABI split or an App Bundle delivers one, which is 620 to 650 KB compressed and about 1.2 MB unpacked. That is the honest number to put in a decision, rather than the “it adds several megabytes” figure I had been repeating. The four ABIs are not an accident either; they are the Android entries in the requiredNativeTargets list in sqlite-bundled/build.gradle, where the Android targets are also the ones compiled with -Oz for size while every other platform gets -O3.
You get something for those bytes: a SQLite version you control, identical across every device and every OS release, instead of whatever the vendor shipped. For an app that uses FTS5, JSON functions, or PRAGMA behaviour that changed between SQLite releases, that is the actual reason to bundle, and the deadlock fix is a bonus.
One caveat from the bundled driver’s own documentation: the amalgamation is compiled in multi-thread mode, so its connections are not individually thread-safe. That is fine precisely because Room puts a real pool in front of them. It is not fine if you hand those connections around yourself.
Why did the driver swap silently change PRAGMA synchronous?
Because Room sets PRAGMA synchronous itself, per connection, based on the journal mode, and it does so without telling you. From RoomConnectionManager.kt:
| |
On AndroidSQLiteDriver this was invisible to me, because I had set PRAGMA synchronous = FULL in a RoomDatabase.Callback.onOpen and there was only ever one connection for it to apply to. Once Room was running its own WAL pool, the setting survived on the first connection and nowhere else, and the asymmetry is explicit in the same file. The two configuration paths are deliberately different:
| |
The public reference for RoomDatabase.Callback.onOpen documents the behaviour in one sentence: “Called when the database has been opened.” Nothing there says once per database rather than once per connection, and with a single-connection pool the difference never shows up. Note also that the Room 3 signature is open suspend fun onOpen(connection: SQLiteConnection): you are handed a connection, which reads like a per-connection hook and is not one.
Worth being precise about what the downgrade costs, because “Room turned off fsync” would be wrong. SQLite’s own documentation is unusually clear here:
WAL mode is always consistent with synchronous=NORMAL, but WAL mode does lose durability. A transaction committed in WAL mode with synchronous=NORMAL might roll back following a power loss or system crash. Transactions are durable across application crashes regardless of the synchronous setting or journal mode. The synchronous=NORMAL setting provides the best balance between performance and safety for most applications running in WAL mode.
The database never corrupts, and an app crash loses nothing. What NORMAL gives up is the last committed transaction across a power loss or a kernel panic. Room’s default is defensible, and the SQLite documentation Room links to recommends it. It was wrong for my case because the table in question is an outbox: a row written there is the only record that a user’s work exists before it reaches the server, and losing the most recent commit on a device whose battery died is exactly the case the outbox exists to survive. FULL is 2 in SQLite’s encoding, and SQLite calls it “atomic, consistent, isolated, and durable (ACID) in WAL mode”.
Pick deliberately. If your WAL database is a cache or a materialised read model, leave Room’s NORMAL alone.
How do you catch a pragma that only applies to some connections?
With an instrumented test that reads the pragma back on a writer connection. I only found the regression because I had written one, and I had written it for a different reason: after the driver change I wanted proof that WAL was still on. Room exposes the connection directly for this:
| |
The failure was two integers:
expected: 2
but was : 1
2 is FULL, 1 is NORMAL. A couple of dozen other instrumented database tests passed, because none of them cared about durability settings. This is the kind of assertion that looks like test-suite padding right up until the day it earns its place. If you configure pragmas on an Android database, assert them on a connection rather than trusting the callback that set them.
useWriterConnection and useReaderConnection are suspending functions on RoomDatabase and are the honest way to ask; a raw query through a DAO may well land on a different connection than the one you mean to inspect.
How do you force a pragma on every connection Room opens?
Wrap the driver, not the database. Room calls SQLiteDriver.open() for each connection it creates, which is the only hook that sees all of them. Intercepting the pragma statement itself is the smallest change that works:
| |
The inTransaction() override is the part I got wrong first, and my initial explanation of why was also wrong. I assumed the interface’s default implementation answered from the wrapper’s own state and therefore returned a stale false. It does not answer at all. From SQLiteConnection.nonWeb.kt:
| |
Without the delegation, every DAO function that writes inside a transaction fails, because internalPerform calls transactor.inTransaction() on the way in. In my case that was every test in one DAO class at once, which at least made the cause obvious. The reason the omission compiles cleanly is that both of these are default interface members, and they have been since the version I was on. The frozen signature file sqlite/api/2.7.0-rc01.txt records it:
public interface SQLiteConnection extends java.lang.AutoCloseable {
method public default boolean inTransaction();
public interface SQLiteDriver {
method @InaccessibleFromKotlin public default boolean hasConnectionPool();
property public default boolean hasConnectionPool;
A Kotlin class implementing SQLiteConnection compiles without inTransaction() and crashes the first time Room needs it. Treat every default member of a driver or connection interface you wrap as something you must forward on purpose.
What I would check first next time
- No crash, no ANR, process alive: stop reading code, send SIGQUIT, read
/data/anr/trace_00. - A Room frame in a blocked stack: check which driver is in the builder before anything else.
PassthroughConnectionPoolin the trace means one connection for the whole process. - After changing the driver: re-assert every pragma you rely on, on a writer connection, not in the callback that sets it.
- After wrapping a driver or connection: list the interface’s default members and forward all of them.
- Before blaming your code: read the release notes for the exact library version you are on. The answer was in a one-line entry about
@TransactionDAO wrapper suspend functions, and it took me longer to find than the thread dump did.
Versions this was reproduced and fixed on
| Component | Version |
|---|---|
androidx.room3:room3-runtime | 3.0.2 (current stable at time of writing: 3.0.3) |
androidx.sqlite:sqlite | 2.7.0 (current: 2.7.1) |
| Kotlin | 2.4.10 |
minSdk / compileSdk | 26 / 37 |
| Emulator | Android 16 |
<< Previous Post
|
Next Post >>