
Explore why using async* generators for infinite scroll pagination in Flutter hides subtle concurrency crashes, and discover how BlocSignal solves rapid scrolling race conditions streamlessly with droppable().
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.
async* and StreamIterator Crack Under PressureWhile moving the page counter and hasMore flag inside the generator prevents outside tampering, the implementation suffers from severe architectural pitfalls:
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.
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:
With an iterator, you still have to maintain an external state container to accumulate batches, catch errors, and update the UI.
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 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.
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));
}
}
}
droppable() is Glitch-Free and StreamlessHow 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:
PostsFetched event arrives, isProcessing flips to true synchronously in the exact same call frame.if (isProcessing) return; and is safely, immediately discarded.finally resets isProcessing = false, allowing the next scroll boundary trigger to proceed.Zero race conditions. Zero microtask lag. Zero Stream allocations.
page CounterNotice 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.
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.
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 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 |
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:
bloc_signals on pub.devbloc_signals_flutter on pub.dev
geminiA controlled Gemini 3.7 Flash benchmark shows why agentic video is excellent for long-form search—but...
aiAI engineering sounds fancy. New terms are everywhere: agentic development, AI-native engineering,...
aiMost teams running on Google Cloud don't pick one compute model and stay there. Some services live...
devchallengeWe're back with another DEV Weekend Challenge, a short bite-sized challenge planned to fit into your...
flutterDiscover how to eliminate Flutter StatefulWidget boilerplate and overcome Dart's single-inheritance wall by combining ScrollController with CubitSignalMixin and BlocSignalMixin for a 100% StatelessWidget UI.
discussTL;DR I recently finished a project from Udacity's Future AWS Agent Engineer Nanodegree Program,...
Workflows from the Neura Market marketplace related to this Midjourney resource