Taming Flutter Infinite Scroll: Why 3 Lines of async*…
    Neura Market
    Neura Market
    /Midjourney
    Marketplace
    Directories
    Resources
    Midjourney
    ChatGPTChatGPTClaudeClaudeGeminiGeminiCursorCursorGrokGrokPerplexityPerplexityDeepSeekDeepSeekCoPilotCoPilotStable DiffusionStable DiffusionMidjourneyMidjourney
    OverviewPromptsBlogVideosGuidesCoursesCommunityStylesTrending
    MidjourneyBlogTaming Flutter Infinite Scroll: Why 3 Lines of async* Missed the Point, and How BlocSignal Fixes It
    Back to Blog
    Taming Flutter Infinite Scroll: Why 3 Lines of async* Missed the Point, and How BlocSignal Fixes It
    flutter

    Taming Flutter Infinite Scroll: Why 3 Lines of async* Missed the Point, and How BlocSignal Fixes It

    Randal L. Schwartz September 3, 2026
    0 views

    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().


    series: "BlocSignal Architecture & Practice" title: "Taming Flutter Infinite Scroll: Why 3 Lines of async* Missed the Point, and How BlocSignal Fixes It" description: "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()." tags: flutter, dart, architecture, statemanagement published: true

    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:

    1. When the first PostsFetched event arrives, isProcessing flips to true synchronously in the exact same call frame.
    2. 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.
    3. Once the HTTP request completes and state is emitted, finally resets isProcessing = 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 MetricAli's async* + StreamIteratorManual Flags in Widget / StateBlocSignal with droppable()
    Concurrency Guard❌ Throws StateError on concurrent moveNext()⚠️ Brittle isLoading flags prone to race conditions✅ 100% synchronous guard lock
    Cursor ManagementScoped to generatorMutable page counterNatural offset (posts.length)
    Search CancellationRequires manual generator recreationComplex cancellation tokensBuilt-in via transformer: restartable()
    UI IntegrationPull-only chunk fetchingCluttered widget codeDeclarative, reactive UI via signals
    Lifecycle & TeardownManual iterator.cancel()Manual controller disposalAutomatic container cleanup via close()
    Runtime OverheadStream iteration overheadMinimalStreamless 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:

    1. Bulletproof concurrency that gracefully ignores rapid thumb flings.
    2. Zero-drift pagination powered by natural list offset indexing.
    3. 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_signals on pub.dev
    • 📦 bloc_signals_flutter on pub.dev
    • 🌐 Official Documentation: blocsignal.dev
    • 🌟 GitHub Repository: RandalSchwartz/BlocSignal

    Tags

    flutterdartarchitecturestatemanagement

    Comments

    More Blog

    View all
    Gemini Agentic Video Isn't Always Cheaper: A 24-Run Benchmarkgemini

    Gemini Agentic Video Isn't Always Cheaper: A 24-Run Benchmark

    A controlled Gemini 3.7 Flash benchmark shows why agentic video is excellent for long-form search—but...

    J
    JimmyLiao
    1
    AI Engineering Is Easy. Changing How We Work Is Hardai

    AI Engineering Is Easy. Changing How We Work Is Hard

    AI engineering sounds fancy. New terms are everywhere: agentic development, AI-native engineering,...

    U
    ujja
    1
    Kong AI Gateway 2.0 on Google Cloud: Securing GKE, Cloud Run, and Vertex AI(Agent Platform)ai

    Kong AI Gateway 2.0 on Google Cloud: Securing GKE, Cloud Run, and Vertex AI(Agent Platform)

    Most teams running on Google Cloud don't pick one compute model and stay there. Some services live...

    S
    Saurabh Mishra
    1
    Join our DEV Weekend Challenge: Generosity Edition! $1,000 in Prizes Across FIVE Winners. Submissions Due September 7 at 6:59 AM UTC.devchallenge

    Join our DEV Weekend Challenge: Generosity Edition! $1,000 in Prizes Across FIVE Winners. Submissions Due September 7 at 6:59 AM UTC.

    We're back with another DEV Weekend Challenge, a short bite-sized challenge planned to fit into your...

    J
    Jem
    Taming Flutter Infinite Scroll (Part 2): Turning ScrollController into a Reactive State Machine with CubitSignalMixinflutter

    Taming Flutter Infinite Scroll (Part 2): Turning ScrollController into a Reactive State Machine with CubitSignalMixin

    Discover 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.

    R
    Randal L. Schwartz
    1
    I Built My First AWS Agent Workflow, and the Hardest Part Was Getting It to Stop Assuming Thingsdiscuss

    I Built My First AWS Agent Workflow, and the Hardest Part Was Getting It to Stop Assuming Things

    TL;DR I recently finished a project from Udacity's Future AWS Agent Engineer Nanodegree Program,...

    H
    Hemapriya Kanagala
    1

    Stay up to date

    Get the latest Midjourney prompts, rules, and resources delivered to your inbox weekly.

    Neura Market LogoNeura Market

    Discover the best AI prompts, plugins, and resources for Midjourney and more.

    Content Types

    • Prompts
    • Blog
    • Videos
    • Guides
    • Courses
    • Community
    • Styles

    Platforms

    • ChatGPT Directory
    • Claude Directory
    • Gemini Directory
    • Cursor Directory
    • Grok Directory
    • Perplexity Directory
    • DeepSeek Directory
    • CoPilot Directory
    • Stable Diffusion Directory
    • Midjourney Directory
    • All Directories

    Resources

    • Blog
    • Documentation
    • Help Center
    • Marketplace

    Legal

    • Privacy Policy
    • Terms of Service

    © 2026 Neura Market. All rights reserved.

    |

    Not affiliated with any AI platform vendors.

    Neura Market

    Custom AI Systems & Services

    Our team of experienced AI builders will help build custom AI systems, workflows, and solutions.

    Request custom work

    Ready-made automations for this

    Workflows from the Neura Market marketplace related to this Midjourney resource

    • Build AI Agents with Think-Plan-Act Architecture Using Llama-4 Reasoningn8n · $24.99 · Related topic
    • Process Documents with Recursive Chunking Using Google Drive, OpenAI & Gemini RAGn8n · $14.99 · Related topic
    • Create a REST API for PDF Digital Signatures with Webhooksn8n · $24.99 · Related topic
    • Auto-Publish Content to 9 Social Platforms with Blotato & Airtablen8n · $24.99 · Related topic
    Browse all workflows