skip to content

In a Flutter infinite ListView, why can one scroll fire several requests for the same page, and how do you prevent it?

level: middleimportance: must knowfreq 50%

answer

  1. listener fires on every offset change
  2. still under the threshold mid-request
  3. flag set before the first await
  4. generation counter after refresh
  5. no auto-retry after an error

basics

~20 s

The scroll listener fires on every offset change and extentAfter stays under the threshold while the request runs, so each event starts another fetch. Set an in-flight flag synchronously before the first await, and discard responses from before a refresh.

solid answer

~50 s

A `ScrollController` listener runs on every change of the offset, many times during one fling, and `position.extentAfter` stays below the threshold for the whole round trip, so every event calls `loadMore()` again. The fix is an in-flight guard checked and set **synchronously, before the first `await`**: `if (_loading || !_hasMore) return; _loading = true;`. Setting it inside the `setState` that applies the result is too late, because every event before that line has already sent its request. Two related races need their own guard: a refresh that starts while a load-more is still running, which I handle by bumping a generation counter and dropping responses whose captured generation no longer matches; and a failed request, which must set an error state that stops the listener, or every scroll event retries. As a last defence I skip items whose id is already in the list.

code

dart · 14 lines
dart
// Broken fragment of a State class: the flag is only set once the response is back.
void _onScroll() {
  if (!_loading && _controller.position.extentAfter < 600) _loadMore();
}

Future<void> _loadMore() async {
  final page = await widget.loadPage(_nextPage, 25);
  setState(() {
    _loading = true; // too late: every scroll event before this line sent a request
    _jobs.addAll(page);
    _nextPage++;
    _loading = false;
  });
}

go deeper

for a junior

Remember that a scroll listener fires many times while one request is running, so loadMore needs a loading flag that it checks and sets straight away.

for a middle

Explain why the flag must be set before the first await, why Dart's single event loop makes that enough, and how hasMore and an error state stop further calls.

for a senior

Cover the races you have seen in production: a refresh overtaken by a stale load-more, a finally block clearing someone else's flag, a retry storm, and overlapping offset pages.

for a principal

Weigh where the guard should live, in the widget or in a shared pager or state object, so every screen that pages data gets the same protection.

## Why one scroll sends many requests A **duplicate fetch** is two or more requests for the same page, usually producing repeated rows or pages loaded out of order. In a Flutter load-more list it has a handful of causes: 1. **The listener is noisy.** A `ScrollController` listener is called on every change of the scroll offset, which during a drag or a fling means many times per second. 2. **The condition stays true.** `position.extentAfter < threshold` remains true for the whole network round trip, because nothing has been added to the list yet. 3. **The guard is set too late.** If the `loading` flag is set after an `await`, or inside the `setState` that applies the response, every listener call before that point has already started its own request. 4. **The footer-build trigger re-runs.** Starting a load from the `itemBuilder` when it builds the footer repeats whenever the footer rebuilds. 5. **A refresh races a load-more.** A pull-to-refresh replaces the list while an older page request is still in flight; when that response lands it is appended to the fresh list. 6. **A failure retries immediately.** If an error leaves no trace in state, the next scroll event sees 'not loading, under the threshold' and fires again: a retry storm. ## The in-flight guard Dart runs a `State`'s code on one isolate's event loop, so a check followed by an assignment with no `await` between them cannot be interleaved. That makes a plain boolean enough: - check `if (_loading || !_hasMore) return;` at the top of `loadMore()`; - set `_loading = true` on the very next line, before any `await`; - clear it in a `finally`, so an exception cannot leave the list stuck in 'loading' forever. | Guard | How it reads | Notes | |---|---|---| | boolean flag | `if (_loading) return; _loading = true;` | simplest; the caller gets nothing to await | | stored `Future` | return the running future instead of starting another | callers can await the same load; clear it when it completes | | debounce timer | wait N ms after the last scroll event | reduces calls but does not guarantee one request per page; not a guard on its own | ## Discarding stale responses A plain `Future` in Dart has no cancel method, so a refresh cannot stop a load-more that is already running. Instead, make the stale response harmless: - keep an integer **generation** that every refresh increments; - capture it before the `await` in `loadMore()`; - after the `await`, if the captured value no longer matches, return without touching the list. The same check tells the `finally` block whether it still owns the `loading` flag, so an old request cannot clear the flag a newer refresh has set. ## Errors and the end of the data - After a failure, store the error. The listener checks it and stays quiet; the footer shows a retry button that calls `loadMore()` directly, which clears the error first. - When a page comes back shorter than the page size, set `hasMore` to false; the guard then refuses every further call, and the footer row disappears. ## A last line of defence: de-duplicate by id Even with a perfect client guard, offset-based pages can overlap when rows are inserted on the server between two requests: page 2 then starts with the last posting of page 1. Skipping items whose id is already present keeps the list clean. It is a safety net, not the guard: without the in-flight flag the app still sends the extra requests and pays for them. How to design a page cursor that does not overlap is a server-side pagination question. ## Checklist 1. One `loadMore()` entry point, used by the listener and the retry button. 2. Synchronous check-and-set of the in-flight flag. 3. `hasMore` checked in the same guard. 4. An error state that silences the automatic trigger. 5. A generation counter bumped by refresh and checked after every `await`. 6. A `mounted` check before `setState` after an `await`.

  • Is a debounce on the scroll listener enough to stop duplicate page requests?
    No. A debounce reduces how often the listener acts, but if the request takes longer than the debounce window, the next quiet moment still sees 'under the threshold' and fires again. It can sit in front of the guard to save work; the in-flight flag set before the first `await` is what guarantees one request per page.
  • Why is a boolean enough, with no lock, when two scroll events arrive close together?
    A `State` object's code runs on one isolate's event loop, one event at a time. A check followed by an assignment with no `await` in between runs to completion before the next listener call can start, so the second call always sees `_loading == true`. The race only opens if an `await` sits between the check and the set.
  • What goes wrong if the finally block always clears the loading flag?
    An old load-more that finishes after a refresh started would clear the flag the refresh still owns. The listener then sees 'not loading' and requests the next page against a list that is being replaced. Checking the captured generation in `finally` keeps each request from touching state it no longer owns.

saying these in an interview costs you the question

  • Set the loading flag inside the setState that applies the response.
  • A debounce timer on the scroll listener guarantees one request per page.
  • Two scroll events can interleave between the check and the set, so a lock is needed.
  • RefreshIndicator cancels any load-more request that is still running.
  • After an error, let the listener retry on the next scroll event.