DEV Community

Cover image for SQLite WAL Mode, Connection Pooling, and Room's Query Planner
SoftwareDevs mvpfactory.io
SoftwareDevs mvpfactory.io

Posted on • Originally published at mvpfactory.io

SQLite WAL Mode, Connection Pooling, and Room's Query Planner

---
title: "SQLite WAL Mode and Room Query Optimization for Mobile"
published: true
description: "A hands-on guide to SQLite WAL vs DELETE journal modes, Room InvalidationTracker pitfalls, connection pooling, and index strategies that keep offline-first Android apps under 16ms frame budgets."
tags: android, kotlin, architecture, performance
canonical_url: https://blog.mvpfactory.co/sqlite-wal-mode-room-query-optimization-mobile
---

## What You Will Build

By the end of this tutorial, you will have a concrete playbook for making your Room-backed Android database perform under real offline-first pressure. We will walk through WAL mode configuration, connection pool sizing, taming Room's InvalidationTracker, and the exact index patterns that keep sync operations under the 16ms frame budget. Let me show you a pattern I use in every project.

## Prerequisites

- An Android project using Room (AndroidX)
- Basic familiarity with SQLite and Kotlin coroutines
- A table with at least 10K+ rows to see meaningful differences (a syncing messages table works perfectly)

## Step 1: Understand WAL vs DELETE Journal Mode

SQLite offers two journal modes, and the performance gap on mobile is not subtle.

| Characteristic | DELETE (rollback) | WAL (write-ahead log) |
|---|---|---|
| Concurrent reads during write | Blocked | Allowed |
| Write performance | 1x baseline | ~1.5–2x faster for small txns |
| Read performance under write load | Degrades significantly | Stable |
| Disk I/O pattern | Random | Sequential (WAL append) |
| Checkpoint cost | None | Periodic; can stall if deferred |

Room enables WAL by default on API 16+ when using `Room.databaseBuilder()`. Here is the gotcha that will save you hours: WAL only helps if you actually perform concurrent reads and writes. If your offline sync writes 500 rows while the UI queries the same tables, WAL is the difference between dropped frames and smooth scrolling.

## Step 2: Size Your Connection Pool

Room's default pool size is calculated like this:

Enter fullscreen mode Exit fullscreen mode


kotlin
val poolSize = if (journalMode == WAL) {
max(2, Runtime.getRuntime().availableProcessors())
} else {
1
}


On a modern 8-core mobile SoC, that gives you up to 8 reader connections. But each connection consumes roughly 1–2 MB for page cache and prepared statement caches. On a memory-constrained device, 8 connections mean 8–16 MB just for SQLite overhead.

The docs do not mention this, but I have found the sweet spot for most offline-first apps is **4 reader connections + 1 writer**. Profile with `PRAGMA cache_stats` before going higher.

## Step 3: Tame Room's InvalidationTracker

Room's `InvalidationTracker` observes **table-level** changes to trigger `Flow` and `LiveData` recomputations — not row-level. This means:

Enter fullscreen mode Exit fullscreen mode


kotlin
@Query("SELECT * FROM messages WHERE conversation_id = :convId")
fun getMessages(convId: String): Flow>


When *any* row in `messages` changes — even in a different conversation — this `Flow` re-emits. During a sync of thousands of messages, every active observer recomputes. Here is the minimal setup to get this working with controlled polling instead:

Enter fullscreen mode Exit fullscreen mode


kotlin
@Query("SELECT * FROM messages WHERE conversation_id = :convId")
fun getMessagesRaw(convId: String): List

fun getMessages(convId: String): Flow> = flow {
var lastHash = 0
while (true) {
val messages = getMessagesRaw(convId)
val currentHash = messages.hashCode()
if (currentHash != lastHash) {
emit(messages)
lastHash = currentHash
}
delay(250)
}
}


This trades the push model for a controlled poll. Under heavy sync loads, it eliminates the O(n) recomputation storm where n is the number of active observers.

## Step 4: Use EXPLAIN QUERY PLAN and Targeted Indexes

Run `EXPLAIN QUERY PLAN` on your generated queries to catch full table scans:

Enter fullscreen mode Exit fullscreen mode


sql
EXPLAIN QUERY PLAN
SELECT * FROM messages WHERE conversation_id = ? ORDER BY timestamp DESC LIMIT 50;
-- Without index: SCAN TABLE messages (~40ms on 100K rows)
-- With index: SEARCH USING INDEX (~0.3ms)


**Partial indexes** reduce index size by filtering rows:

Enter fullscreen mode Exit fullscreen mode


sql
CREATE INDEX idx_unsynced ON messages (created_at)
WHERE sync_status = 0;


If 95% of messages are synced, this index is 20x smaller than a full index on `created_at`.

**Covering indexes** eliminate table lookups entirely:

Enter fullscreen mode Exit fullscreen mode


sql
CREATE INDEX idx_msg_covering ON messages (
conversation_id, timestamp DESC, id, body, sender_id
);


| Strategy | Size (100K rows) | Query time | Table lookups |
|---|---|---|---|
| No index | 0 KB | ~40ms | Full scan |
| Standard index | ~2.4 MB | ~1.2ms | Per-row |
| Partial index (5%) | ~120 KB | ~0.4ms | Per-row |
| Covering index | ~8 MB | ~0.3ms | Zero |

## Gotchas

- **WAL checkpoint stalls**: WAL files grow until checkpointed. If you defer checkpoints too long, a large checkpoint can stall writes. Monitor WAL file size during extended sync sessions.
- **InvalidationTracker is table-granular**: There is no way to scope it to specific rows. For high-churn tables with many active observers, manual change detection consistently outperforms the built-in mechanism.
- **Covering indexes trade disk for speed**: They consume more storage but eliminate I/O at query time. Worth it for hot UI paths, overkill for rarely-accessed tables.
- **More connections ≠ more performance**: Each SQLite connection carries memory overhead. Always profile on your lowest-tier supported device before increasing pool size beyond 4.

## Wrapping Up

Audit your `Flow` observers per table, run `EXPLAIN QUERY PLAN` on every critical-path DAO method, and set your connection pool explicitly. Any `SCAN TABLE` on 10K+ rows is a frame budget risk. These are not exotic optimizations — they are the baseline for offline-first apps that stay smooth under real sync pressure.
Enter fullscreen mode Exit fullscreen mode

Top comments (0)