Engineering

Your list flickers because the page swap is not atomic

Replacing a cached page is several row operations, and a reader arriving between them observes a state that never existed. Make the swap one transaction.

MobileByteSensei 10 min read
A split-flap row caught mid-change, showing an arrangement that means nothing
kotlindataarchitecture

A paginated list refreshes. The new page arrives, replaces the old one, and the list is correct afterwards. For a fraction of a second during the replacement it shows something wrong — a gap, a duplicate, a jump in scroll position — and then it settles.

The replacement is several operations. The reader does not know that.

The case this post is about

One cached page being replaced, and a reader that arrived in the middle of it.

Replacing page two of a list is several row operations: delete the old rows, insert the new ones, update the page's bookkeeping. A reader observing between the delete and the insert sees a list with a hole in it — page one, nothing, page three — which is a list that never existed at any point in the data.

On screen it is a flicker, or a gap, or a spinner that appears for no reason. It reads as a rendering bug, so it gets investigated in the UI, which is the wrong layer entirely.

This post follows that one swap, because the fix is a transaction boundary and the hard part is noticing that the symptom is not where the cause is.

The symptom

What gets reported is a flicker, and flicker is a word that sends people to the rendering layer. The list jumps, or an item appears twice, or the whole thing empties and refills. All of those read as animation problems, and the first hour goes into the UI.

The cause is underneath: replacing a cached page means deleting the rows that were there and inserting the ones that arrived. Those are separate writes. Any reader observing between them sees a list with the old rows gone and the new ones not yet present, which is a state that was never intended and is briefly true.

The old reading gone, the new one not yet arrived, and the gap between them

The second thing is that it is worse the better your architecture is. A list driven by an observable query over local storage re-emits on every write — that is the point of it. So a swap implemented as delete-then-insert does not emit once with the new content; it emits twice, and the first emission is the empty intermediate. A less reactive design would have missed the gap entirely.

The third is the duplicate, which is the same bug with the operations in the other order. Insert first, then delete the old rows, and a reader in between sees both copies. This is the version people reach for after seeing the gap, and it trades an empty flash for a doubled one.

The fourth is scroll position, which is the most annoying and the hardest to argue is a data problem. If the list empties for one frame, the view's scroll offset has nowhere to anchor, so it resets. The user is reading item forty, the list blinks, and they are at the top again — and nothing in the data layer looks like it did that.

Why the obvious fix failed

Debounce the UI. Delay rendering briefly so the intermediate state passes unseen. It works, in the sense that the flicker becomes rare, and it is a timing fix for a correctness problem — the wrong state is still being published, you are just hoping nobody is looking. It also adds latency to every legitimate update.

Diff in the view layer. Make the list compute what actually changed and animate only that. Good for other reasons and it does not help here: the view is diffing against an intermediate state that should not have been emitted, so it faithfully animates a removal and then a re-insertion.

Insert before delete. The instinct after seeing the gap, and it produces duplicates instead. Both orderings are wrong for the same reason, which is a useful signal: the problem is not the order, it is that there is an order at all.

Hold the old page until the new one is ready, in memory. Closer, and it moves the swap from storage into application code — which means the correctness now depends on every reader going through that code, and a second reader querying storage directly sees the raw intermediate again.

The actual mechanism

Make the replacement one transaction, so no reader can observe between the parts.

transaction {
    delete rows for this page
    insert the new rows
}

That is the entire fix, and the interesting part is why it is sufficient. A transaction does not make the operations faster or reorder them — it makes the intermediate state unobservable. Readers see the state before or the state after, and the thing that never existed to the application never exists to them either.

The observable query cooperates with this if it is driven by the transaction rather than by individual writes: one commit, one emission, carrying the final content. The double emission disappears because there was only ever one state change, which is what you meant all along.

The change made behind a shutter, so only settled states are ever on display

The sub-case that is easy to miss: the transaction has to cover the page boundary as well as the page. A paginated list usually keeps an ordering or a set of page keys alongside the rows, and if that bookkeeping is updated outside the transaction, you have moved the inconsistency rather than removed it — rows and their index briefly disagreeing, which produces a subtler version of the same symptom and is harder to spot because the rows themselves are fine.

The row settled and its index left behind, each mechanism correct on its own

There is a related decision about what a refresh of page one should do to pages two and beyond. If the first page changed, the later pages may no longer follow from it, and the honest options are to discard them or to keep them and accept they may be inconsistent. Both are defensible; what is not defensible is leaving it unexamined, because the default is "keep them" and the default was not chosen.

The fix

one transaction    delete and insert together, never separately
include the index  page keys and ordering inside the same transaction
one emission       the observable query fires on commit, not per write
decide on later pages   discard or keep, explicitly

All four in one transaction — the index is written inside it, which is the part that gets separated and is the reason a refresh flickers:

@Dao
interface FeedDao {
    @Query("DELETE FROM feed_item WHERE query_key = :key")      suspend fun clear(key: String)
    @Query("DELETE FROM feed_index WHERE query_key = :key")     suspend fun clearIndex(key: String)
    @Insert suspend fun insertItems(items: List<FeedItemEntity>)
    @Insert suspend fun insertIndex(rows: List<FeedIndexEntity>)

    // ONE transaction. Room emits to observers on COMMIT, so the UI sees one new
    // list — never the empty gap between the delete and the insert.
    @Transaction
    suspend fun replaceFirstPage(key: String, items: List<FeedItemEntity>, keepLater: Boolean) {
        if (keepLater) {
            clearPage(key, page = 0)                 // surgical: page 0 only
            clearIndexPage(key, page = 0)
        } else {
            clear(key); clearIndex(key)              // the whole query's cache
        }
        insertItems(items)
        insertIndex(items.mapIndexed { i, it -> FeedIndexEntity(key, it.id, position = i) })
    }

    @Query("SELECT i.* FROM feed_item i JOIN feed_index x ON x.item_id = i.id " +
           "WHERE x.query_key = :key ORDER BY x.position")
    fun observe(key: String): Flow<List<FeedItemEntity>>        // ordering lives in the index
}

Pull-to-refresh on a list the user is looking at:

separate writes:   [42 items] → [] → [42 items]     two emissions; the screen blanks
one transaction:   [42 items] → [42 items]          one emission; nothing flickers

keepLater is the decision people leave implicit. Discarding later pages resets the user's scroll; keeping them risks items that no longer belong to the refreshed query. Either is defensible — not choosing means whichever the code happens to do.

The second and fourth lines are the ones that get left out, and they are where the remaining flicker lives after somebody has wrapped the obvious part. A transaction around half the state change is a transaction that guarantees consistency of half the state.

What makes this cheap is that the storage layer already provides the mechanism. Nothing here needs building; it needs using, and the reason it was not used is that the delete and the insert were written at different times by somebody who did not have both in mind at once.

There is a question of how wide the transaction should be, and the answer is narrower than people reach for once they have been bitten. The instinct after a flicker is to wrap more — the fetch, the parse, the write — and that produces a transaction held open across a network call, which blocks other writers for as long as the server takes. The transaction covers the state change and nothing before it: fetch, parse, and prepare outside; delete, insert and index inside. The expensive part is not the part that needs isolating.

The other thing worth doing deliberately is deciding what happens when the fetch fails. A page swap that has already deleted the old rows has no fallback; one that only enters the transaction once it holds the replacement content can simply not enter it. That ordering is free and it is the difference between a failed refresh leaving the list intact and leaving it empty.

How we proved it

The intermediate state is directly observable, which is what turns this from a theory about flicker into a demonstration. Log every emission from the observable query during a refresh: without the transaction there are two, and the first carries the empty or doubled content. With the transaction there is one. That is the whole argument and it takes a few lines to show.

The scroll-position symptom resolves with it, which is worth noting because it is the one that looked least like a data problem. No change to the view layer is involved; the list stops resetting because it stops being emptied.

Two routes to the same end state, one of which passes through nothing

What I have not measured is how often users actually saw it. The window is one emission wide and depends on how quickly the second write follows the first, so it is likely small and certainly non-zero. I fixed it because the mechanism is clearly wrong, not because I have a rate — and a fraction-of-a-second wrong state is exactly the kind of thing that generates vague bug reports rather than countable ones.

What I have not solved: the multi-page refresh. Replacing one page atomically is straightforward; replacing several so that the whole list is consistent at every moment is a larger transaction with a longer lock, and I have not worked out where the limit is. At the moment I refresh page by page, which means the list is internally consistent per page and may briefly be inconsistent across pages.

The diagnosis is the transferable part, more than the fix. "Flicker" is a word that routes a bug to the rendering layer, and the routing is wrong often enough to be worth a habit: when something visual is briefly incorrect and then correct, ask what the data layer published during that window before touching the view. The emission log answers it in minutes and it answers it definitively, because either an intermediate state was published or it was not.

That habit generalises past this bug. A view can only render what it was given, so a transient visual wrong is either a view that mishandled a correct sequence or a view that faithfully rendered a wrong one — and those are distinguishable by looking at the sequence rather than at the view.

And a limitation worth stating: atomicity makes the intermediate unobservable to readers of that storage. Anything holding its own copy — a view model with a cached list, a widget, an export in progress — is outside the transaction and can still be mid-swap. The guarantee has a boundary and the boundary is the storage layer, not the application.

One thing I got wrong on the first attempt: I wrapped the delete and insert and left the page-key update outside, because it lived in a different class and the transaction was in the storage layer. The flicker became much rarer and did not go away, and I spent an afternoon convinced the transaction was not working before noticing that the rows and their ordering were still being written separately. Half a transaction produces a quieter version of the same bug, which is a worse place to be than the loud one.

What this generalizes to

Any state change implemented as more than one write has an intermediate state, and the only question is whether anything can see it.

That question is easy to answer wrongly, because the intermediate is usually brief and the observer is usually absent. Systems get built on the assumption that nobody is watching between two statements, and that assumption holds right up until the architecture becomes more reactive — at which point every intermediate state becomes a value that gets published, rendered and acted upon.

So the useful question when designing a multi-step change is not whether the end state is correct. It is what a reader arriving halfway would see, and whether anything prevents them arriving. If the answer is a state that should not exist and nothing prevents it, the fix is almost always a transaction you already have available and did not reach for.

One post a week, no digest

New writing when it lands. No roundups, no course pitch, no "10 tips".

Follow by RSS