The Ubiquitous Infinite Scroll Pagination Bug
Almost every Flutter engineer has encountered the dreaded infinite scroll race condition i...
For further actions, you may consider blocking this person and/or reporting abuse
The guard you object to in Ali's version is still the guard in yours, it just changed owner.
droppable()as you posted it is a mutable boolean flipped before the await and cleared infinally, which is the same shape as the one Future you quote him defending, so what improved is ergonomics and placement rather than the elimination of hand-managed concurrency state.The part I would want in the state model is the drop itself.
if (isProcessing) return;emits nothing, so N discarded scroll triggers leave no trace, and paired with your failure path (status: failurewhilehasReachedMaxstays false) a fling that fails on the in-flight page ends with the list at rest at the bottom, in failure, with no request pending. A retry then needs another threshold crossing, and the thumb has already stopped.So I would have the transformer either count drops into state or re-emit the current state on a drop, so the UI can tell "nothing was asked" apart from "asked and discarded". Same reasoning you use for concurrency being an event-boundary concern, applied one step further: the boundary is also where the decision to discard becomes invisible.
Great observation, Vinh! A couple of thoughts on why this separation is intentional:
Encapsulation vs. Hand-Managed State: A Mutex or Semaphore is also "just an atomic flag that changed owner." The win isn't pretending concurrency state doesn't exist; it's moving imperative
try/finallyplumbing out of every single domain handler into a declarative, reusable event boundary policy (transformer: droppable()) that you configure once and never have to debug again.The Failure Path UX: If an in-flight page fails, having the list come to rest at the bottom in
status: failureis actually the desired, predictable UX. You don't want it to silently loop or require another scroll fling. In production infinite scroll, a failure renders an error footer (for example: "Couldn't load posts. [Tap to Retry]"). Becausefinallyalready clearedisProcessing = falseon failure, tapping "Retry" (add(const PostsFetched())) executes immediately without requiring any scroll threshold crossings.Why Dropped Events Shouldn't Emit State: A vigorous thumb fling can emit dozens of scroll ticks during a 300ms network round-trip. If we re-emitted state or incremented a
dropCountin domain state on every dropped trigger, we'd trigger high-frequency UI evaluations and widget rebuilds while an async request is already in-flight—defeating the exact performance optimizationdroppablewas created for.Where Discard Visibility Belongs: Concurrency dropping is an event boundary concern, not domain state. If you do want telemetry or diagnostic visibility into discarded events, that belongs in
BlocSignalObserver(for example a futureonEventDropped(bloc, event)hook for DevTools and OpenTelemetry spans), keeping the core domain state model clean and focused purely on the UI's data requirements.Your cost objection settles cleanly against bloc's own equality gate, and it splits the two things I suggested unevenly. Re-emitting the current state is a strict no-op:
BlocBase.emitreturns atif (state == _state && _emitted) return;before it ever reaches_stateController.add, so it costs nothing and tells the UI nothing. That half was wrong. The counter half does emit, but rebuild frequency there is bounded by distinct states rather than by dropped ticks: encode it as adroppedSinceRequestbool and a fling of N ticks during one in-flight page produces exactly one state change, because the first sets it and the remaining N-1 hit that same equality line. So the objection that survives is the domain-state one, not the frequency one, and separating them matters becauseBloc.observeris a static field: anonEventDroppedhook is process-global and still has to come back through an emit before a widget can tell 'never asked' from 'asked and dropped'.Fair point on the equality gate, Vinh—you're completely right that a
droppedSinceRequestboolean would collapse N scroll ticks into at most a single state transition (false -> true) rather than N emissions. That is a sharp distinction!That brings us directly to the architectural core of the question: Why should the UI ever care about "never asked" versus "asked and dropped"?
Here is why keeping that distinction out of the widget tree remains the cleanest design:
Intent vs. Trigger Redundancy: When a user flings a list past the bottom threshold, all N triggers during that single gesture express the exact same user intent: "Fetch the next page." The very first trigger already accepted and initiated that work. The subsequent dropped events are not separate requests that were denied; they are redundant triggers of an operation already actively running. The UI already reflects that reality (for example, by displaying a bottom loading indicator). There is no production UI state where a widget would render differently based on "asked and dropped" while that exact fetch is already pending.
Transformers are Schedulers, Not State Producers: In BLoC architecture, an
EventTransformerhas a single responsibility: event scheduling and temporal coordination. It governs when (or if) events reach the handler.droppable()is a generic, reusable primitive across arbitrary events and states (EventTransformer<E, StateType>); it has zero knowledge of domain state fields. If a transformer mutated state directly to flip adroppedSinceRequestflag, it would break unidirectional data flow (where only event handlers map events to state) and couple generic event transformers to specific domain models.Frame Budget During High-Velocity Flings: Even bounding the emission to a single transition (
false -> true), triggering a widget rebuild and reactive tree evaluation right in the middle of a high-velocity scroll fling taxes the main thread at the worst possible moment—when Flutter is actively recycling, laying out, and rasterizing slivers at 120Hz. Emitting an invisible state transition for no visual change risks dropping frames during the most motion-critical phase of the interaction.Telemetry vs. Reactive State:
BlocObserverbeing process-global is actually the appropriate boundary here. Dropped event frequency is valuable telemetry for diagnostic profiling, threshold sensitivity tuning, and OpenTelemetry or DevTools spans—not reactive state that UI widgets should be observing or binding to.Point 1 has the qualifier that decides it: "while that exact fetch is already pending." The case I meant is the one after it resolves —
status: failure,hasReachedMaxstill false, nothing pending. The drops during the fling are the reason no further trigger arrives, and by then the finger has stopped.On 2 and 3, I think both go away if nothing emits at drop time.
EventTransformeris a plain function (bloc.dart:33), and in bloc_concurrency 0.3.0droppablereachesmapper(data)atdroppable.dart:38, one line past the drop guard at:35. So arrivals minus mapper calls is the dropped count, and you get it by wrapping the two argumentsdroppablealready takes — no reimplementation, and no transformer touching domain state. The handler folds that number into the state it already emits on completion or failure. Zero extra emissions, nothing at all during the fling.That's read from source, not run — I don't have Dart on this machine. And 4 I'll give you outright: if the count never has to reach a widget, the observer is the right home for it.
Glad we converged on #4, Vinh — if the count never needs to reach a widget,
BlocObserver/BlocSignalObserver(and downstream DevTools or OpenTelemetry) is 100% the rightful home for drop telemetry.That leaves the deferred
onDropaccumulator and the "stopped thumb" failure scenario. Deferring the drop tally until the in-flight request finishes is a neat mechanical solve for the 120 Hz frame budget during the fling. But when we look at how that plays out in production architecture, a couple of core issues remain:Hardware Sampling Noise in Domain State:
Scroll notification ticks are an artifact of display refresh rates and input physics. A user performing the exact same flick gesture on a 120 Hz ProMotion screen might generate 30 threshold ticks, while on a 60 Hz screen they generate 15, on a mouse wheel 3, and on a precision trackpad 50. Folding that count into domain state (
FeedLoadedorFeedFailure) means domain state is now tracking input hardware sampling variance rather than domain semantics.Actionability in the "Stopped Thumb" Failure Path:
You are entirely right that once the list comes to rest at the bottom on
Status.failure, the thumb has stopped and no further scroll notifications will arrive. But having that drop count does not give the UI or the container any actionable recovery path:droppablemutex), tapping "Retry" dispatchesFetchNextPageRequested()immediately. It executes without requiring any scroll threshold crossings.Transformer Purity:
Keeping
droppableas a pure event-scheduling policy means handlers stay focused strictly on processing data ($Event \to State$) without having to coordinate with external accumulators or event-transformer lifecycle callbacks.So if the drop count is hardware-sampling noise, does not change the recovery affordance when the thumb stops, and does not belong in the widget tree, keeping drops strictly in the observer leaves domain state clean, transformers generic, and the UI simple.
This essay is a detailed architectural critique that uses a common Flutter problem—infinite scroll race conditions—as a case study to argue for a broader principle: concurrency control belongs at the event boundary, not buried inside imperative data-pulling loops or scattered across UI scroll listeners. The critique of the async* generator approach is technically precise and fair: while moving the page counter and hasMore flag inside the generator prevents outside tampering, Dart's StreamIterator.moveNext() is explicitly not concurrency-safe, throwing a runtime error if called while a previous call is still pending, which means the author still had to maintain a manual guard—so the abstraction did not eliminate the concurrency problem; it merely relocated it. The essay's own solution, BlocSignal's droppable() transformer, is a clean, streamless implementation that flips a synchronous isProcessing flag in the same call frame, discarding any additional events that arrive while a request is in flight, and because the check is synchronous rather than relying on Rx streams or microtask dispatch, it avoids both race conditions and the latency overhead of stream-based transformers. The natural cursor pagination insight—deriving the offset directly from stateValue.posts.length rather than maintaining a separate page counter—is a subtle but important architectural point: a separate counter can desynchronize from the actual item count if an API request fails or duplicate events trigger, while deriving from the source of truth eliminates an entire class of bugs. The restartable() transformer for search cancellation is another well-argued feature: when a new search query arrives, it increments an internal token that drops any in-flight HTTP responses from older queries, avoiding ghost responses without manual teardown or cancellation tokens. The comparison table at the end is useful for summarizing the trade-offs, though it is written from the author's perspective and could be more balanced in acknowledging that the async* approach is simpler for developers who do not want to adopt a full state management library. The essay is strongest in its technical precision and its insistence that concurrency is not a UI concern but an event scheduling concern, and that handling it at the event boundary with explicit transformers produces more reliable, testable code than scattering isLoading flags across widgets. The one limitation is that the essay is written as part of a series promoting BlocSignal, so it is not a neutral comparison but an argument for a specific architectural choice; readers should evaluate the trade-offs against their own project constraints, team familiarity, and the overhead of adopting a new library. Overall, the essay makes a compelling case that pagination bugs are not inevitable but are often the result of conflating UI gestures with concurrency policy, and that separating those concerns with event transformers and a reactive state model produces code that is both more reliable and more maintainable.
Thanks for the thoughtful and precise breakdown, Mona!
You hit the exact nail on the head: moving the iterator into a generator didn't eliminate the concurrency hazard, it just relocated where the guard had to live. Dart's
StreamIterator.moveNext()isn't concurrency-safe, so without event boundary enforcement, you're always one unhandled UI bounce away from an unhandled runtime error.Regarding the trade-offs: that is a completely fair point. For a small utility app or isolated prototype where you just need quick pagination, pulling from a local generator with an imperative boolean guard is undeniably lighter than introducing any state management architecture.
Where that breaks down in production is when UI gestures inevitably multiply—search debouncing, pull-to-refresh canceling in-flight loads, tab switching, or retries. Concurrency policy belongs at the event boundary, not scattered across widgets or nested inside data-pulling loops.
Really appreciate you taking the time to read through the mechanics in detail!