The Ubiquitous Infinite Scroll Pagination Bug
Almost every Flutter engineer has encountered the dreaded infinite scroll race condition in production.
The user opens a list, flings their thumb down the screen on a spotty cellular connection, and triggers multiple scroll notifications past the bottom threshold within milliseconds. Before the first asynchronous HTTP network request finishes, the scroll listener fires again.
Suddenly, your list duplicates items, page counters jump ahead, or the state machine locks up entirely.
Recently, mobile developer Ali Wajdan published a widely discussed article titled 3 Lines of Dart async* Code That Fixed My Infinite Scroll Pagination.
In his article, Ali accurately diagnoses the root cause of standard pagination headaches:
"Most Flutter pagination code I have seen, including my own for years, wraps a mutable state object around a scroll listener. A page counter, a loading boolean, a hasMore flag, and a fetch method the UI calls when it hits the scroll threshold. It works until two scroll events fire close together, or a rebuild triggers a second load before the first future resolves... It is a classic race condition, and it gets worse once the state lives across a page counter, a hasMore flag, and a loading flag that all need to stay in sync."
To escape this trap, Ali suggested encapsulating pagination logic inside a Dart async* generator and consuming it with a StreamIterator:
// The pattern proposed in Ali Wajdan's article
Stream<List<Post>> fetchPostsPaginated(String query) async* {
var page = 0;
var hasMore = true;
while (hasMore) {
final batch = await api.fetchPosts(query, page: page);
hasMore = batch.isNotEmpty;
page++;
yield batch;
}
}
final iterator = StreamIterator(fetchPostsPaginated(query));
Future<List<Post>> loadNextPage() async {
if (!await iterator.moveNext()) return const [];
return iterator.current;
}
On the surface, moving mutable state into local generator variables looks clean. But does it actually solve the concurrency problem in production?
Let us take a closer look.
🔍 Why async* and StreamIterator Crack Under Pressure
While moving the page counter and hasMore flag inside the generator prevents outside tampering, the implementation suffers from severe architectural pitfalls:
1. The Hidden Concurrency Crash: Bad state: Cannot call moveNext...
Dart's StreamIterator.moveNext() is explicitly not concurrency-safe.
If a fast scroll fling or a double-rebuild calls loadNextPage() while a previous moveNext() is still awaiting the network, Dart immediately throws an unhandled runtime error:
Bad state: Cannot call moveNext while a previous call to moveNext is still pending.
Because of this, the author admits in the article that he still had to maintain a manual guard:
"I still guard the UI trigger with the one Future that loadNextPage returns... That guard is the only piece of state I own now."
In other words, you have not actually eliminated the concurrency guard—you have merely introduced a stream iterator abstraction on top of it.
2. Pulling Chunks vs. Reactive Unidirectional Data Flow
An iterator is an imperative pull-based consumer. It yields a raw batch of items, but a real-world Flutter UI needs a comprehensive reactive state model:
- What happens when a network error occurs on page 4?
- How does the UI render a bottom spinner indicator while retaining previously fetched items?
- How do we handle pull-to-refresh or empty states?
With an iterator, you still have to maintain an external state container to accumulate batches, catch errors, and update the UI.
3. Resource Leak Risks & Teardown Friction
A StreamIterator holds an active stream subscription. If the user navigates away from the screen, you must remember to explicitly invoke await iterator.cancel(). Furthermore, whenever a search query or filter changes, you must tear down the old iterator, instantiate a fresh generator, and re-bind the pipeline.
⚡ The Architectural Lesson: Concurrency Belongs at the Event Boundary
The core insight is simple: concurrency control should never be buried inside an imperative data-pulling loop, nor should it leak into UI scroll listeners.
Concurrency is an event scheduling concern. The moment a user's gesture or scroll threshold emits an event, the system should declare how overlapping executions are handled.
In BlocSignal, concurrency is a first-class citizen governed by streamless event transformers.
🛡️ The Idiomatic BlocSignal Solution: droppable()
In bloc_signals, preventing duplicate requests during infinite scroll requires exactly one parameter: transformer: droppable().
Here is how a clean, production-ready PostsBloc looks:
import 'package:bloc_signals/bloc_signals.dart';
import 'package:flutter/foundation.dart';
import '../models/post.dart';
sealed class PostsEvent {
const PostsEvent();
}
final class PostsFetched extends PostsEvent {
const PostsFetched();
}
final class PostsSearchChanged extends PostsEvent {
const PostsSearchChanged(this.query);
final String query;
}
enum PostsStatus { initial, loading, success, failure }
@immutable
class PostsState {
const PostsState({
this.status = PostsStatus.initial,
this.posts = const [],
this.hasReachedMax = false,
this.searchQuery = '',
});
final PostsStatus status;
final List<Post> posts;
final bool hasReachedMax;
final String searchQuery;
PostsState copyWith({
PostsStatus? status,
List<Post>? posts,
bool? hasReachedMax,
String? searchQuery,
}) {
return PostsState(
status: status ?? this.status,
posts: posts ?? this.posts,
hasReachedMax: hasReachedMax ?? this.hasReachedMax,
searchQuery: searchQuery ?? this.searchQuery,
);
}
}
class PostsBloc extends BlocSignal<PostsEvent, PostsState> {
PostsBloc({required PostRepository repository})
: _repository = repository,
super(initialState: const PostsState()) {
// 1. Drop duplicate scroll triggers while a page fetch is in flight
on<PostsFetched>(
_onPostsFetched,
transformer: droppable(),
);
// 2. Automatically cancel and restart when the search query changes
on<PostsSearchChanged>(
_onPostsSearchChanged,
transformer: restartable(),
);
}
final PostRepository _repository;
Future<void> _onPostsFetched(
PostsFetched event,
void Function(PostsState) emit,
) async {
if (stateValue.hasReachedMax) return;
try {
// Natural offset pagination: stateValue.posts.length IS your cursor!
final newPosts = await _repository.fetchPosts(
query: stateValue.searchQuery,
startIndex: stateValue.posts.length,
limit: 10,
);
emit(
newPosts.isEmpty
? stateValue.copyWith(hasReachedMax: true)
: stateValue.copyWith(
status: PostsStatus.success,
posts: [...stateValue.posts, ...newPosts],
hasReachedMax: newPosts.length < 10,
),
);
} catch (_) {
emit(stateValue.copyWith(status: PostsStatus.failure));
}
}
Future<void> _onPostsSearchChanged(
PostsSearchChanged event,
void Function(PostsState) emit,
) async {
emit(stateValue.copyWith(
status: PostsStatus.loading,
searchQuery: event.query,
));
try {
final posts = await _repository.fetchPosts(
query: event.query,
startIndex: 0,
limit: 10,
);
emit(PostsState(
status: PostsStatus.success,
posts: posts,
hasReachedMax: posts.length < 10,
searchQuery: event.query,
));
} catch (_) {
emit(stateValue.copyWith(status: PostsStatus.failure));
}
}
}
🔬 Under the Hood: Why droppable() is Glitch-Free and Streamless
How does droppable() prevent race conditions without allocating Rx streams or microtask queues?
In classic package:bloc_concurrency, transformers convert an incoming event stream using Rx operators (such as exhaustMap). That introduces stream controllers, subscription pipelines, and asynchronous microtask dispatch delays.
In BlocSignal, event transformers are streamless higher-order functions:
EventTransformer<E, StateType> droppable<E, StateType>() {
var isProcessing = false;
return (event, handler, emit) async {
if (isProcessing) return;
isProcessing = true;
try {
final result = handler(event, emit);
if (result is Future) {
await result;
}
} finally {
isProcessing = false;
}
};
}
Look at how elegant this is:
- When the first
PostsFetchedevent arrives,isProcessingflips totruesynchronously in the exact same call frame. - If the user's scroll fling generates 8 additional scroll events in that same frame or while the HTTP request is pending, each incoming event hits
if (isProcessing) return;and is safely, immediately discarded. - Once the HTTP request completes and state is emitted,
finallyresetsisProcessing = false, allowing the next scroll boundary trigger to proceed.
Zero race conditions. Zero microtask lag. Zero Stream allocations.
🎯 Natural Cursor Pagination: Forgetting the page Counter
Notice another detail in PostsBloc: there is no page counter variable anywhere.
When you manage paginated lists, maintaining a separate int page = 0 counter that increments alongside posts.addAll(...) is an anti-pattern. If an API request fails, or if duplicate events trigger, the counter can desynchronize from the actual item count.
Instead, derive your offset directly from the source of truth:
startIndex: stateValue.posts.length
The length of your accumulated list is your pagination cursor. There is nothing to desynchronize, nothing to increment prematurely, and nothing to reset manually.
🔄 Instant Search Reset with restartable()
What happens when the user types a new search query into the search bar while an infinite scroll request is actively in flight?
In Ali's generator example, resetting required manual teardown:
"Changing a search query or filter means creating a new generator, not carefully resetting three fields and hoping you got them all."
In BlocSignal, you simply tag search events with transformer: restartable():
on<PostsSearchChanged>(
_onPostsSearchChanged,
transformer: restartable(),
);
When a new search query arrives, restartable() increments an internal token. Any in-flight HTTP responses from older queries or prior scroll pages are automatically dropped from emitting state. The list smoothly switches to the new search query without race conditions or ghost responses.
📱 The Flutter UI: Declarative and Lightweight
Consuming this in Flutter is straightforward. We attach a ScrollController listener to dispatch PostsFetched() when the user is within 10% of the bottom, and build the UI using BlocSignalBuilder:
class PostsView extends StatefulWidget {
const PostsView({super.key});
@override
State<PostsView> createState() => _PostsViewState();
}
class _PostsViewState extends State<PostsView> {
final _scrollController = ScrollController();
@override
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
}
@override
void dispose() {
_scrollController.removeListener(_onScroll);
_scrollController.dispose();
}
void _onScroll() {
if (!_scrollController.hasClients) return;
final maxScroll = _scrollController.position.maxScrollExtent;
final currentScroll = _scrollController.offset;
// Trigger when 90% scrolled
if (currentScroll >= (maxScroll * 0.9)) {
context.read<PostsBloc>().add(const PostsFetched());
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Infinite Scroll Posts'),
bottom: PreferredSize(
preferredSize: const Size.fromHeight(60),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
child: TextField(
decoration: const InputDecoration(
hintText: 'Search posts...',
prefixIcon: Icon(Icons.search),
border: OutlineInputBorder(),
),
onChanged: (query) {
context.read<PostsBloc>().add(PostsSearchChanged(query));
},
),
),
),
),
body: BlocSignalBuilder<PostsBloc, PostsState>(
builder: (context, state) => switch (state.status) {
PostsStatus.initial => const Center(
child: CircularProgressIndicator(),
),
PostsStatus.failure => const Center(
child: Text('Failed to load posts.'),
),
PostsStatus.loading && state.posts.isEmpty => const Center(
child: CircularProgressIndicator(),
),
PostsStatus.success || PostsStatus.loading => state.posts.isEmpty
? const Center(child: Text('No posts found.'))
: ListView.builder(
controller: _scrollController,
itemCount: state.hasReachedMax
? state.posts.length
: state.posts.length + 1,
itemBuilder: (context, index) {
if (index >= state.posts.length) {
return const Padding(
padding: EdgeInsets.all(16.0),
child: Center(child: CircularProgressIndicator()),
);
}
final post = state.posts[index];
return ListTile(
leading: CircleAvatar(child: Text('${post.id}')),
title: Text(post.title),
subtitle: Text(post.body),
);
},
),
},
),
);
}
}
Because BlocSignalBuilder listens to fine-grained signal changes, UI updates execute synchronously in the exact frame state is emitted, completely avoiding frame-skipping and microtask latency.
📊 Architectural Comparison
| Architectural Metric | Ali's async* + StreamIterator
|
Manual Flags in Widget / State |
BlocSignal with droppable()
|
|---|---|---|---|
| Concurrency Guard | ❌ Throws StateError on concurrent moveNext()
|
⚠️ Brittle isLoading flags prone to race conditions |
✅ 100% synchronous guard lock |
| Cursor Management | Scoped to generator | Mutable page counter |
Natural offset (posts.length) |
| Search Cancellation | Requires manual generator recreation | Complex cancellation tokens | Built-in via transformer: restartable()
|
| UI Integration | Pull-only chunk fetching | Cluttered widget code | Declarative, reactive UI via signals |
| Lifecycle & Teardown | Manual iterator.cancel()
|
Manual controller disposal | Automatic container cleanup via close()
|
| Runtime Overhead | Stream iteration overhead | Minimal | Streamless pure Dart functions |
Conclusion
Ali Wajdan's article highlights a genuine problem: manual state flags around scroll listeners are a frequent source of production bugs in Flutter.
However, attempting to solve event concurrency by turning asynchronous APIs into stream generators swaps one set of bugs for another.
By treating concurrency as an event boundary policy with droppable() and restartable(), you gain:
- Bulletproof concurrency that gracefully ignores rapid thumb flings.
- Zero-drift pagination powered by natural list offset indexing.
- Streamless performance with 0ms signal propagation.
Resources & Links
- 🔗 Original Article by Ali Wajdan: 3 Lines of Dart async* Code That Fixed My Infinite Scroll Pagination
- 📜 Interactive Concurrency Guide: Event Concurrency Transformers on blocsignal.dev
- 💻 Full Runnable Example & Test Suite: examples/infinite_scroll on GitHub
- 📦
bloc_signalson pub.dev - 📦
bloc_signals_flutteron pub.dev - 🌐 Official Documentation: blocsignal.dev
- 🌟 GitHub Repository: RandalSchwartz/BlocSignal
Top comments (12)
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.
Our discussion sparked an even broader architectural realization: why should
droppablebe a special case?Concurrency transformers make all sorts of pipeline decisions:
droppable()discards events while busy ('event_dropped')restartable()preempts and cancels active in-flight tasks ('event_preempted')sequential()queues events ('event_queued', queue depth, and wait latency)debounce()/throttle()coalesces or delays rapid burstsRather than inventing a narrow, one-off
onDroppedhook, we just opened a GitHub tracking issue (#239) to design a first-class, generic telemetry pipeline for event transformers.The proposal introduces
BlocSignalObserver.onTelemetry(bloc, name, {event, metadata}). Transformers can report structured pipeline events without polluting domain state or altering handler signatures. That telemetry then flows directly into Flutter DevTools timeline badges and OpenTelemetry spans/counters.Thanks for the stimulating conversation, Vinh — it crystallized an observability primitive that will benefit the entire reactive ecosystem!
Point 1 is right and it does more damage than you aimed it at: it lands on the observer too. If the tick count is a function of display refresh and input physics, then 30 against 15 against 3 for the same gesture is just as uninterpretable in
BlocObserver, DevTools or OpenTelemetry as it is inFeedFailure. Moving it does not make it comparable across devices — the number is still measuring the user's hardware. So the fix your argument actually demands is not relocation, it is changing what the quantity counts.Two normalisations survive it. Drops per resolved request divides out the thing whose duration the drops are a proxy for. Better, a boolean: did the guard drop at least one event while
statuswas failure. That one has no hardware term in it at all, because it only asks whether the drop path was taken, and every device you listed answers yes on the same gesture over the same failed fetch.Point 2 I concede as written, and the boolean is why. You are right that the widget is identical at 0 and at 12, and that "Tap to retry" dispatches without needing a threshold crossing — I was arguing the count could reach a UI decision, and it cannot. But once the quantity is one bit, it stops wanting to be in domain state at all: a bit that no widget reads is diagnostic, not domain semantics. Which puts me at your conclusion by a different road. The observer is the right home because the quantity is diagnostic, not because the count is noise — and I would rather arrive there with a per-request bit that means something than with a tick count that does not survive a change of screen.
Point 3 stands unopposed. Nothing in the above needs the transformer to see domain state or to coordinate with an accumulator, which was the part of my earlier sketch that deserved the pushback.
You nailed the philosophical core of it, Vinh: "a bit that no widget reads is diagnostic, not domain semantics." Arriving at that separation—even by a different road—is the exact architectural dividing line between UI domain state and system observability.
Your critique of Point 1 is razor-sharp. If an observability dashboard naively plots raw dropped tick counts, a 120 Hz ProMotion display looks like it has "twice the problem" of a 60 Hz screen for the exact same physical gesture. Moving an uncalibrated scalar from
FeedFailuretoBlocObserverdoesn't magically make it meaningful across different client devices.Here is how modern observability handles that distinction in practice, and why your normalization insight is so valuable:
Span Events vs. Raw Counters in OpenTelemetry:
When
droppable()emits telemetry, OpenTelemetry records it as an event attached to the active request's Span, rather than an isolated global tick counter. In distributed tracing, the presence of at least one drop event during that span automatically flags the span withcontention: true—which gives you the exact hardware-invariant, per-request boolean you identified, without the transformer ever needing to inspect the request's domain lifecycle.What Actually Survives Across Devices:
As you pointed out, the terms that actually carry diagnostic meaning across different hardware are:
Transformer Purity Preserved:
Because
droppable()simply emits a discreteevent_droppedrecord toBlocSignalObserver.onTelemetryat the moment of the drop, the transformer remains a zero-dependency scheduler. It doesn't need an internal accumulator, doesn't need to know about request start/finish boundaries, and doesn't know what device it is running on. The observer—or the telemetry exporter—decides whether to track it as a discrete trace marker, aggregate it into contention ratios, or record your one-bit diagnostic.This entire dialogue has been a masterclass in dissecting where concurrency scheduling ends, where domain state begins, and how observability should measure the space in between. It has already made the telemetry design in #239 significantly more rigorous!
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!
Some comments may only be visible to logged-in visitors. Sign in to view all comments.