MVP Factory
ai startup development

Memory-mapped I/O for Android SQLite: Eliminating read latency spikes

KW
Krystian Wiewiór · · 6 min read

SEO Meta Description: Learn how SQLite’s mmap_size pragma, WAL mode, and access pattern tuning eliminate p99 read latency spikes on memory-constrained Android devices.

TL;DR

Standard read() syscalls for SQLite on Android are subject to filesystem buffer cache eviction, causing unpredictable p99 latency spikes — especially on memory-constrained devices. Enabling memory-mapped I/O via PRAGMA mmap_size bypasses the double-buffering problem, letting SQLite access database pages directly from the kernel page cache. But mmap is not a universal win: it depends on your access patterns, your SQLite build’s compile-time SQLITE_MAX_MMAP_SIZE limit, and whether you are in WAL mode. This is the configuration and profiling approach that actually works in production.

The problem: why read latency spikes happen

In my experience building production systems that handle content-heavy local databases — cached feeds, offline article stores, health tracking logs — the most insidious performance issue is not average read time. It is the p99 tail.

The standard read() path works like this:

  1. SQLite maintains its own page cache (default ~2MB via PRAGMA cache_size).
  2. The OS also caches file data in the filesystem buffer cache.
  3. When memory pressure hits (common on 3-4GB Android devices), the kernel evicts buffer cache pages.
  4. SQLite’s next read() call triggers a blocking disk I/O, spiking latency from ~0.1ms to 15-50ms.

So you’re paying memory overhead for two caches and still eating cold reads when it matters most.

What mmap actually does differently

When you enable PRAGMA mmap_size = 268435456 (256MB), SQLite maps the database file into its address space. Reads become pointer dereferences into kernel-managed pages instead of read() syscalls.

AspectStandard read()Memory-mapped I/O
Syscall overhead per page1 read() call0 (page fault on miss)
Double bufferingYes (SQLite cache + buffer cache)No (single page cache)
Kernel cache eviction recoveryBlocking read() + context switchMinor page fault (faster)
p99 latency (4GB device, 80% memory pressure)15-50ms1-5ms
Sequential scan performanceGoodExcellent (kernel read-ahead)
Random small lookupsGoodGood to slightly better
Risk on 32-bit processesLowAddress space exhaustion

The average-case improvement is modest. What matters is tail latency under memory pressure — the exact moment your user is multitasking and your app needs to feel responsive.

The Android-specific gotcha: compile-time limits

Android’s bundled SQLite ships with SQLITE_MAX_MMAP_SIZE set to 0 on many OEM builds prior to API 27, which disables mmap entirely. Even on newer builds, the limit varies.

// Check effective mmap support at runtime
val db = SQLiteDatabase.openOrCreateDatabase(path, null)
val cursor = db.rawQuery("PRAGMA mmap_size = 268435456", null)
cursor.moveToFirst()
val effectiveMmapSize = cursor.getLong(0)
// If this returns 0, your SQLite build does not support mmap

If you need guaranteed mmap support, you have two realistic options:

  • Requery’s SQLite Android bindings — bundles a recent SQLite with mmap enabled
  • SQLCipher — also bundles its own SQLite; confirm SQLITE_MAX_MMAP_SIZE in their build config

WAL mode interaction

mmap and WAL mode are complementary, but the interaction matters more than people expect. In WAL mode, reads come from both the main database file and the WAL file. SQLite’s mmap only applies to the main database file — the WAL is always read via standard I/O.

This means CHECKPOINT behavior directly impacts mmap effectiveness. Aggressive checkpointing (e.g., PRAGMA wal_autocheckpoint = 100) moves data from the WAL back into the main file where mmap can serve it. If your WAL grows unbounded, most of your hot reads bypass mmap entirely, and you’ve gained almost nothing.

db.execSQL("PRAGMA journal_mode = WAL")
db.execSQL("PRAGMA wal_autocheckpoint = 100")
db.execSQL("PRAGMA mmap_size = 268435456")

Access pattern heuristics: when mmap hurts

mmap is not universally better. The right call depends on your dominant access pattern:

  • Sequential scans (feed loading, batch sync): mmap wins easily. The kernel’s read-ahead prefetching maps naturally to sequential page faults.
  • Random point lookups (key-value access, indexed queries): mmap is roughly equivalent or slightly better, since you eliminate syscall overhead but still fault on cold pages.
  • Large table scans on very constrained devices (<2GB RAM): mmap can hurt. Mapping a 500MB database into address space under heavy memory pressure causes a storm of page faults and potential OOM kills. I’ve watched this happen on a budget device in a QA lab and it is not subtle.

Profiling with Perfetto

You can profile mmap fault behavior directly using Perfetto’s ftrace integration:

# In your Perfetto trace config, enable:
# - category: "mm_filemap" (page cache events)
# - category: "filemap" (mmap fault tracing)

Look for mm_filemap_add_to_page_cache events — high frequency of these during database reads indicates cold mmap faults. Compare trace profiles with and without mmap to quantify the difference for your specific workload. I’ve seen teams skip this step and deploy mmap blindly, only to regret it on low-RAM devices. Don’t be that team.

Practical configuration

Most teams get this wrong by enabling mmap globally without tuning for device capability. A production-ready approach adapts:

fun configureSQLiteMmap(db: SQLiteDatabase) {
    val activityManager = context.getSystemService<ActivityManager>()
    val memoryInfo = ActivityManager.MemoryInfo()
    activityManager.getMemoryInfo(memoryInfo)

    val totalRamMb = memoryInfo.totalMem / (1024 * 1024)

    val mmapSize = when {
        totalRamMb >= 6000 -> 256 * 1024 * 1024L  // 256MB
        totalRamMb >= 4000 -> 128 * 1024 * 1024L   // 128MB
        totalRamMb >= 3000 -> 64 * 1024 * 1024L    // 64MB
        else -> 0L  // Disable on low-RAM devices
    }

    db.execSQL("PRAGMA mmap_size = $mmapSize")
}

Device-aware tuning matters most in apps that run persistent background tasks. Apps like HealthyDesk that periodically wake to deliver break reminders need their SQLite reads to be fast even when system memory is under pressure from foreground apps.

What to do with all this

Enable mmap with device-aware sizing. Don’t use a single hardcoded value. Gate mmap_size based on available RAM, and disable it entirely on devices below 3GB.

Keep your WAL checkpointed. mmap only covers the main database file. Set wal_autocheckpoint aggressively (100-200 pages) so hot data lives where mmap can serve it.

Profile before and after with Perfetto. Measure p99 read latency and page fault frequency on your actual target devices. mmap eliminates tail latency on mid-to-high-end hardware but can worsen OOM risk on the low end. Let the trace data drive your configuration, not assumptions.

TAGS: android, kotlin, architecture, mobile, backend


Share: Twitter LinkedIn