Scaling an Android Gallery from 100,000 to 1,000,000 Files

Scaling an Android Gallery from 100,000 to 1,000,000 Files

# android# mobile# kotlin# performance
Scaling an Android Gallery from 100,000 to 1,000,000 FilesSasha Kosobutsky

I am building SyncGallery, an Android gallery with configurable cloud sync. The examples below come...

I am building SyncGallery, an Android gallery with configurable cloud sync. The examples below come from real customer failures, heap dumps, device profiles, and the code we changed after reading them.

Our original scale target was 100,000 media files.

That sounded generous. At 30 new photos per day, it is about nine years of photos. Then real libraries showed up with old archives, phone migrations, imported camera cards, and large SD cards. The app did not suddenly fail at file 100,001. It became slower and heavier in lots of small places.

We are now designing for one million files.

Important disclaimer: one million is our new design target, not a benchmark we have completed across the whole app. The measurements in this post come from real scenarios between 1,000 and 100,000 records. The point is to explain what those measurements taught us, and what had to change before a million could be realistic.

The snippets are simplified from production code.

1. A count should not build a screen

The first expensive mistake looked innocent. A filter needed to show how many files matched a sync rule, so it reused the same projection that built the visible rows.

Conceptually, the old path did this:

val count = buildExplorerEntries(rule).size
Enter fullscreen mode Exit fullscreen mode

That one line hid a lot of work. For one rule, the app materialized 11,112 ExplorerEntry objects and checked 8,810 paths on disk. It took 4,049 ms to produce one number.

We replaced the projection with a batched aggregate:

SELECT ruleId, COUNT(*) AS count
FROM cloud_sync_mapping
WHERE excluded = 0
  AND ruleId IN (:ruleIds)
GROUP BY ruleId
Enter fullscreen mode Exit fullscreen mode

In the measured sessions, the filter count dropped from about 1,350 ms to 8-230 ms, depending on the state of the rule and database.

There was a second problem in the same path. Presence checking called File(path).exists() for every row. A rule with 3,760 entries spent 1,720 ms doing individual file stats.

Most files lived in a small number of directories, so we grouped paths by parent, listed each directory once, and answered membership from an in-memory name set. In one capture, 8,810 paths were spread across 34 directories. That changed 8,810 file stats into 34 directory listings.

The rule I wrote down after this: if the UI needs a scalar, compute a scalar. If it needs membership, batch around the storage shape instead of asking the filesystem the same kind of question thousands of times.

2. "Latest result wins" does not cancel old work

We had several background recomputations protected by generation counters. The idea was reasonable: start some work, and only publish if no newer generation has appeared.

The shape looked like this:

nextGeneration += 1
val generation = nextGeneration

viewModelScope.launch {
    val result = recomputeBadges()
    if (generation == nextGeneration) {
        publish(result)
    }
}
Enter fullscreen mode Exit fullscreen mode

This prevented stale results from reaching the UI. It did not stop stale work.

A heap dump at 25,000 mappings showed:

ExplorerBadgeTarget       513,893 live instances
ExplorerBadgeIdentity      57,106 live instances
LinkedHashMap.Entry      1,244,611 live instances
App heap                        217 MB shallow
Enter fullscreen mode Exit fullscreen mode

There were only about 57,000 distinct badge identities, but around nine full generations were alive at once. There was no classic leak and no single object dominating the heap. Several valid coroutines were all doing obsolete work and holding their own copies.

We changed global recomputation to a conflated channel with one long-lived consumer:

private val recomputeSignals = Channel<Unit>(Channel.CONFLATED)

private fun requestRecompute() {
    recomputeSignals.trySend(Unit)
}

private fun consumeRecomputes() {
    viewModelScope.launch(Dispatchers.IO) {
        for (signal in recomputeSignals) {
            recomputeBadges()
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Now there can be at most one recomputation in flight and one queued signal. A burst collapses into the latest pending request. For work scoped to one gallery pane, we cancel that pane's previous job before starting its replacement.

Generation checks are still useful for publication correctness. They are not a concurrency limit. If stale work is expensive, it needs cancellation, conflation, or both.

3. Boxed IDs are not free

Android gallery code passes media IDs everywhere, so Long collections are easy to ignore. Heap dumps made them hard to ignore.

At 54,483 items, two collections used only for EXIF progress bookkeeping retained 5.99 MB of a 106.5 MB live heap:

val versions = HashMap<Long, Long>()
val freshIds = HashSet<Long>()
Enter fullscreen mode Exit fullscreen mode

The useful data was tiny. Most of the cost came from boxed Long objects and hash table nodes. We kept the same state and changed only its representation:

val versions = MutableLongLongMap()
val freshIds = MutableLongSet()
Enter fullscreen mode Exit fullscreen mode

The expected retained size fell from 5.99 MB to roughly 1.7 MB, and clearing and refilling the collections could reuse flat arrays instead of rebuilding a node graph.

Another heap dump showed a related problem. Two reverse indexes retained 21.5 MB at rest. They were derived transposes of data we already had, and their only live reader asked questions about the bounded set of rows visible on screen.

We deleted the whole-library reverse indexes and replaced them with chunked database queries for visible rows. That trades a little database latency for a much smaller process-wide live set. The query runs on a conflated IO consumer, so it does not block the main thread or pile up unbounded copies.

The lesson is not "replace every Kotlin collection with a primitive one." It is to do the memory math for any collection with one entry per media file. At one million files, even a small per-entry tax becomes architecture.

4. "Select all" should select a predicate

Three customers on devices with a 256 MiB heap hit OutOfMemoryError with about 90,000 active tasks and 90,000 parked rows.

The parked approval screen was one source of pressure. Confirming a large selection held the same IDs in seven forms: the selected set, a defensive copy, a sorted list, discovered IDs, per-rule batches, committed IDs, and hidden IDs.

The UI said "select all matching rows." The implementation heard "load every matching row ID and keep copying it."

We changed selection from a materialized set to a frozen query specification:

data class AllMatchingSelection(
    val filters: SelectionFilters,
    val maxRowId: Long,
    val excludedIds: Set<Long>,
    val includedIds: Set<Long>,
)
Enter fullscreen mode Exit fullscreen mode

The row ID watermark freezes membership at the moment the user taps Select all. New tasks that arrive later stay visible for the next confirmation instead of silently joining an action the user already approved.

Processing then walks the selection in keyset pages:

SELECT id
FROM parked_tasks
WHERE id > :afterId
  AND id <= :maxRowId
  AND ...
ORDER BY id
LIMIT :pageSize
Enter fullscreen mode Exit fullscreen mode

The important part is not Room or this exact SQL. It is the model: a large selection is usually a predicate plus exceptions, not a giant set of IDs.

That model also gives better crash behavior. A stable watermark makes the approved population explicit, and keyset paging gives each batch a clear continuation point.

5. Choose the list architecture before optimizing it

There are several valid ways to show a large list. They make very different tradeoffs.

Model What stays in memory Main advantage Main risk
Full list Every materialized row Simple code, instant access, exact total and position Memory grows directly with the library
Explicit pages One page, sometimes nearby pages Strong memory bound and simple server queries Page buttons are awkward in a gallery
Continuous loading Every chunk loaded as the user reaches the end Natural endless-scroll experience If old chunks stay, memory eventually becomes a full list
Indexed window Compact keys for the full list, rich rows for one window Continuous scrolling with an exact total and bounded row memory The complete key index still has a memory cost

Four UI patterns for a large gallery: full list, explicit pages, continuous loading, and an indexed window.

The four models are not just different loading animations. They decide what can stay alive in memory.

Continuous loading is often called pagination, but the UX and memory behavior are different from explicit pages. Loading 100 more rows near the end does not make a list memory-safe if every earlier row remains alive. Dropping old chunks bounds memory, but then the app needs stable anchors and a way to recover the user's absolute position.

For our gallery, explicit page buttons were the wrong experience. A fully materialized list was the wrong memory model. We chose an indexed window as the compromise.

Why we keep the complete index

One gallery filter could not use our indexed path, so it always built the full projection. At 100,000 favorites, one refresh materialized about 55 MB.

But the screen did not need 100,000 rich row objects. It needed a stable order, a total count, and enough objects to draw the current window.

With a complete index, the UI knows the exact collection size and the absolute position of the visible window. It can say item 450,000 of 1,000,000 and drive a real scrollbar or position progress indicator. A pure endless list only knows how many rows have arrived so far. It cannot know the real denominator until it reaches the end or runs a separate count query.

The pane therefore stores compact identity for every position and materializes content only for the current window. The simplified production shape is:

class RuleFilterIndex(
    val ids: LongArray,
    val kinds: ByteArray,
) {
    init {
        require(ids.size == kinds.size)
    }
}
Enter fullscreen mode Exit fullscreen mode

The kind byte tells us which database table owns an ID. A window load slices both primitive arrays, fetches only those rows, and publishes the full index size alongside the window:

suspend fun loadWindow(start: Int, size: Int) {
    val end = (start + size).coerceAtMost(index.ids.size)
    val ids = index.ids.copyOfRange(start, end)
    val kinds = index.kinds.copyOfRange(start, end)
    val rows = source.materializeWindow(ruleId, ids, kinds)

    publish(
        rowCount = index.ids.size,
        windowStart = start,
        window = rows,
    )
}
Enter fullscreen mode Exit fullscreen mode

The index needs its own memory budget

Windowing does not make the full-list cost disappear. It changes the cost from rich objects to compact keys, so the key representation becomes load-bearing architecture.

For one million rows, the raw payload is easy to calculate:

LongArray: 1,000,000 * 8 bytes = 7.63 MiB
ByteArray: 1,000,000 * 1 byte  = 0.95 MiB
Combined raw payload                 = 8.58 MiB
Enter fullscreen mode Exit fullscreen mode

There is a little extra array and object overhead, but the payload dominates at this size. If a list needs only one ID, the base index is about 7.63 MiB. Our rule-filter index needs an ID plus a one-byte kind, so its raw budget is about 8.58 MiB.

The representation matters. In this code path, we budget roughly 32 bytes per entry for a boxed List<Long>. At one million rows, that would be about 30.5 MiB for the IDs alone instead of 7.63 MiB. A parallel boxed kind collection would make it worse.

The full projection gives the other end of the tradeoff. If 100,000 rich rows take about 55 MB, a rough linear extrapolation to one million is about 550 MB, before temporary copies or concurrent recomputations. That does not fit a 256 MiB Android heap. An 8.58 MiB index plus a bounded content window can.

This is not "paging fixes everything." The index still needs invalidation, loading it still costs time, and window fetches need stable ordering. The useful boundary is that the whole collection is represented by primitive identity, while expensive objects exist only for the visible window.

What one million means for us

We have not reached a point where every screen can handle one million files. Some paths still contain assumptions from the 100,000-file design, and the rollout is happening screen by screen.

The number is useful anyway because it changes design reviews now.

If a screen keeps one object per file for its whole lifetime, we ask for the memory math. If an event rebuilds the whole collection, we ask how often that event can fire and whether old work stops. If a button says Select all, we ask whether it stores a predicate or a list. If the UI needs a count, a badge, or a visible page, we ask why it would hydrate anything else.

The production results so far are encouraging:

  • One filter workload dropped from 74.5 MB/s to 39.2 MB/s of allocation.
  • Blocking garbage collections dropped from 326 to 19 in comparable captures.
  • A separate cache change reduced a measured heap peak from 216 MB to 137 MB and eliminated observed main-thread GC waits in that capture.
  • The 100,000-favorite filter moved from a roughly 55 MB full projection to an index plus one materialized window.

Those are not one-million-file benchmark results. They are evidence that removing work proportional to the whole library, especially repeated or concurrent work, is the right direction.

My short version is:

  1. Never materialize a collection to compute a count.
  2. Prevent stale work from running, not only from publishing.
  3. Measure boxed IDs and derived indexes as real product memory.
  4. Represent large selections as predicates plus exceptions.
  5. Keep rich objects inside a bounded visible window.

The surprising part was that no single clever algorithm fixed the app. The big improvement came from finding many ordinary places where convenient code quietly copied, boxed, hydrated, or recomputed the whole world.

If you have scaled a media app, file manager, or offline database past 100,000 local records, I would love to hear which assumption broke first for you.


This article was edited with AI assistance and reviewed for technical accuracy by the author.