{"id":212,"date":"2026-09-27T13:50:00","date_gmt":"2026-09-27T11:50:00","guid":{"rendered":"https:\/\/grindloop.io\/blog\/?p=212"},"modified":"2026-09-27T11:43:41","modified_gmt":"2026-09-27T09:43:41","slug":"android-pagination-race-condition","status":"publish","type":"post","link":"https:\/\/grindloop.ai\/blog\/android-pagination-race-condition\/","title":{"rendered":"Why Fast Scrolling Duplicates Items in Your Paginated List"},"content":{"rendered":"<p>A paginated list that duplicates items after a fast scroll usually has an Android pagination race condition. The <code>RecyclerView<\/code> or <code>LazyColumn<\/code> showing the list isn&#8217;t the problem. The bug is a check-then-act race in the repository underneath. A <code>loadNextPage()<\/code> function reads a <code>currentPage<\/code> counter and fetches that page. It appends the results to a shared list, then increments the counter. Nothing serializes those steps. A fast scroll can trigger a second load before the first one finishes. Both calls can then read the same <code>currentPage<\/code> before either one increments it. The same page gets fetched and appended twice. The next page gets skipped. You don&#8217;t need multiple threads for this. The network call suspends. That suspension is enough room for a second coroutine to run the same code.<\/p>\n\n\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"kotlin\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">class PagedRepository(private val api: ListApi) {\n\n    private var currentPage = 0\n    private val loadedItems = mutableListOf&lt;Item&gt;()\n\n    suspend fun loadNextPage(): List&lt;Item&gt; {\n        val page = api.fetchPage(currentPage)\n        loadedItems.addAll(page.items)\n        currentPage++\n        return loadedItems\n    }\n}<\/pre>\n\n\n<h2>Bug one: a pagination race condition on currentPage<\/h2>\n\n<p><code>api.fetchPage(currentPage)<\/code> reads <code>currentPage<\/code>. <code>currentPage++<\/code> writes it. Between those two lines sits a suspending network call. That&#8217;s a real gap in wall-clock time. During it, the dispatcher can run something else. That includes a second <code>loadNextPage()<\/code> call from the same fast scroll. If the second call reads before the first call writes, both coroutines fetch page 0.<\/p>\n\n\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">Coroutine A: reads currentPage \u2192 0, starts fetching page 0\nCoroutine B: reads currentPage \u2192 0, starts fetching page 0 (A hasn't written yet)\nCoroutine A: appends page 0, currentPage++ \u2192 1\nCoroutine B: appends page 0 again, currentPage++ \u2192 2<\/pre>\n\n\n<p>Page 0&#8217;s items are now in <code>loadedItems<\/code> twice. The counter jumped to 2, so page 1 is never fetched. <a href=\"https:\/\/kotlinlang.org\/docs\/shared-mutable-state-and-concurrency.html\" target=\"_blank\" rel=\"noopener\">Kotlin&#8217;s official concurrency guide<\/a> shows the same kind of bug with a plain <code>counter++<\/code>. Its example runs a hundred coroutines on the multi-threaded <code>Dispatchers.Default<\/code>. The guide says the program is &#8220;highly unlikely to ever print&#8221; the correct total. The coroutines increment the counter &#8220;without any synchronization.&#8221; A separate section explains that marking the variable volatile doesn&#8217;t help. Read-then-write is two operations, not one. The pagination version adds a suspending call between them, so it can race even on a single thread.<\/p>\n\n<p>The symptom isn&#8217;t hypothetical. Google&#8217;s official Paging codelab has <a href=\"https:\/\/github.com\/android\/codelab-android-paging\/issues\/173\" target=\"_blank\" rel=\"noopener\">an open GitHub issue about duplicate items after scrolling<\/a>. It was filed in June 2021. The trigger in that report differs from the race described here. The symptom is the same. Duplicated or missing rows near page boundaries keep showing up in real Android code, official samples included.<\/p>\n\n<h2>Bug two: an atomic counter still leaves the list unprotected<\/h2>\n\n<p>Once candidates spot bug one, the usual fix is to make the page counter atomic and stop there.<\/p>\n\n\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"kotlin\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">\/\/ Common wrong answer: fixes the counter, not the list\nclass PagedRepository(private val api: ListApi) {\n\n    private val currentPage = AtomicInteger(0)\n    private val loadedItems = mutableListOf&lt;Item&gt;()\n\n    suspend fun loadNextPage(): List&lt;Item&gt; {\n        val page = currentPage.getAndIncrement()\n        val result = api.fetchPage(page)\n        loadedItems.addAll(result.items)\n        return loadedItems\n    }\n}<\/pre>\n\n\n<p><code>getAndIncrement()<\/code> does close bug one. Two calls can no longer read the same page number. But the list is still shared without protection. Two fetches now run at once. Page 1 can come back before page 0. When that happens, page 1&#8217;s items get appended first and the list is out of order. The risk grows if the repository runs on a multi-threaded dispatcher. <code>mutableListOf()<\/code> returns a plain <code>ArrayList<\/code>. It makes no thread-safety promise under concurrent modification. Two <code>addAll()<\/code> calls racing from different threads can lose items. The function also returns the live list. A caller iterating it while another load appends can hit a <code>ConcurrentModificationException<\/code>. It&#8217;s the same shape as bug one in a different spot. One piece of state is protected while the piece next to it stays exposed. A senior answer treats <code>currentPage<\/code> and <code>loadedItems<\/code> as one unit of state that has to be guarded together.<\/p>\n\n<h2>The fix<\/h2>\n\n<p>The standard fix is a <code>Mutex<\/code> around the whole critical section.<\/p>\n\n\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"kotlin\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">class PagedRepository(private val api: ListApi) {\n\n    private val mutex = Mutex()\n    private var currentPage = 0\n    private val loadedItems = mutableListOf&lt;Item&gt;()\n\n    suspend fun loadNextPage(): List&lt;Item&gt; = mutex.withLock {\n        val page = api.fetchPage(currentPage)\n        loadedItems.addAll(page.items)\n        currentPage++\n        loadedItems.toList()\n    }\n}<\/pre>\n\n\n<p>The <code>Mutex<\/code> wraps the whole read-fetch-append-increment sequence. A second <code>loadNextPage()<\/code> call can&#8217;t read <code>currentPage<\/code> until the first call has updated both fields. Pages load one at a time and in order. Returning <code>toList()<\/code> hands callers a snapshot instead of the live list. The Kotlin guide explains why to use <code>Mutex<\/code> over a blocking lock like <code>synchronized<\/code>. In its words, <a href=\"https:\/\/kotlinlang.org\/docs\/shared-mutable-state-and-concurrency.html\" target=\"_blank\" rel=\"noopener\">&#8220;Mutex.lock() is a suspending function. It does not block a thread.&#8221;<\/a> <code>withLock<\/code> wraps the <code>mutex.lock(); try { ... } finally { mutex.unlock() }<\/code> pattern. A coroutine waiting on the mutex suspends and frees its thread. That matters on Android. A blocking lock held across a network call would tie up a dispatcher thread for the whole call.<\/p>\n\n<p>A quick check confirms both versions. Run two overlapping <code>loadNextPage()<\/code> calls on a single thread, with a fake 100 ms network call, on kotlinx.coroutines 1.9.0. The buggy repository appends page 0 twice. The <code>Mutex<\/code> version loads pages 0 and 1 in order.<\/p>\n\n\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">buggy: [0-0, 0-1, 0-0, 0-1]\nfixed: [0-0, 0-1, 1-0, 1-1]<\/pre>\n\n\n<h2>The rule to remember<\/h2>\n\n<p><strong>A check-then-act sequence split across a suspending call is a race waiting to happen. Guard the whole sequence and all the state it touches with one Mutex.<\/strong><\/p>\n\n<ul>\n<li>Any &#8220;read a variable, do suspending work, write the variable&#8221; sequence is unsafe under concurrent calls. That&#8217;s true with or without multiple threads. The suspension point alone gives another coroutine room to interleave.<\/li>\n<li>Protecting one field while leaving the next one unguarded doesn&#8217;t fix the bug. An <code>AtomicInteger<\/code> counter next to a plain <code>mutableListOf()<\/code> just moves where the bug shows up.<\/li>\n<li><code>Mutex.lock()<\/code> suspends instead of blocking a thread. That&#8217;s why it&#8217;s preferred over <code>synchronized<\/code> or <code>ReentrantLock<\/code> in coroutine code, especially around network calls.<\/li>\n<\/ul>\n\n<h2>How to answer this in an interview<\/h2>\n\n<ol>\n<li>Name the failure mode before touching the code. It&#8217;s a check-then-act race on shared mutable state. It isn&#8217;t a network flake or a UI bug. Explain why. The gap between reading and writing <code>currentPage<\/code> spans a suspending call. That gives another coroutine time to run.<\/li>\n<li>Point out both problems separately. An atomic counter is a real improvement. It doesn&#8217;t protect <code>loadedItems<\/code>. It also lets pages arrive out of order.<\/li>\n<li>Justify <code>Mutex<\/code> over a blocking lock. It suspends the caller instead of blocking a thread. That matters because the critical section wraps a network call.<\/li>\n<\/ol>\n\n<p><strong>Common mistakes:<\/strong><\/p>\n\n<ul>\n<li>Reaching for <code>AtomicInteger<\/code> or <code>@Volatile<\/code> and calling it solved once the counter looks safe. Check whether anything else in the same function is shared mutable state.<\/li>\n<li>Locking only the increment instead of the whole sequence. Two coroutines can still fetch the same page.<\/li>\n<li>Using <code>synchronized<\/code> or a blocking <code>Lock<\/code> inside a <code>suspend fun<\/code>. That can block a dispatcher thread for the length of a network call.<\/li>\n<\/ul>\n\n<hr\/>\n\n<p><em>Related: <a href=\"https:\/\/grindloop.ai\/blog\/why-your-combined-stateflow-shows-stale-data-sharingstarted-lazily-vs-eagerly\/\">why a combined StateFlow shows stale data<\/a>. It&#8217;s another case where fixing one piece of shared state leaves another exposed. GrindLoop&#8217;s Bug-Squash track turns failure patterns like this one into live debugging drills. Each drill comes with a reviewed fix.<\/em><\/p>\n\n<p><strong>Failed the interview? Not the next one.<\/strong><\/p>","protected":false},"excerpt":{"rendered":"<p>An Android pagination race condition duplicates list items when two scroll-triggered page loads overlap. Here&#8217;s the two-bug reason, the fix, and the interview answer.<\/p>\n","protected":false},"author":2,"featured_media":215,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"Android Pagination Race Condition: Duplicate Items Fix","rank_math_description":"Fast scrolling duplicates items when two page loads race on a shared counter and list. Why an atomic counter isn't enough, and how a Mutex fixes both.","rank_math_focus_keyword":"android pagination race condition","footnotes":""},"categories":[9,3],"tags":[15,70,13,12,71,72,45],"class_list":["post-212","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-bug-squash","category-coroutines","tag-android","tag-concurrency","tag-coroutines","tag-kotlin","tag-pagination","tag-race-condition","tag-technical-interview"],"_links":{"self":[{"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/posts\/212","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/comments?post=212"}],"version-history":[{"count":11,"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/posts\/212\/revisions"}],"predecessor-version":[{"id":697,"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/posts\/212\/revisions\/697"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/media\/215"}],"wp:attachment":[{"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/media?parent=212"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/categories?post=212"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/tags?post=212"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}