Skip to content
AB

The Flutter Race Conditions I Fixed with bloc_concurrency

Choose between droppable, restartable, sequential, and concurrent BLoC event handling using pagination, search, and submission examples.

Ashish BhakhandPublished

A screen can look correct in normal testing and still contain an asynchronous race condition.

I started noticing these bugs in features with pagination, search, and rapidly changing filters:

  • scrolling triggered the same next-page request twice;
  • a slow search for an old query replaced results for a newer query;
  • users tapped Submit twice and created duplicate work;
  • month navigation briefly showed data from the month visited earlier.

The event handlers were individually correct. The missing decision was what should happen when another event arrives while the first one is still running?

bloc_concurrency makes that decision explicit.

BLoC events can overlap#

Consider a search BLoC:

Code
on<SearchQueryChanged>(_onQueryChanged);

Future<void> _onQueryChanged(
  SearchQueryChanged event,
  Emitter<SearchState> emit,
) async {
  emit(state.copyWith(isLoading: true));

  final results = await repository.search(event.query);

  emit(
    state.copyWith(
      isLoading: false,
      results: results,
      query: event.query,
    ),
  );
}

The user types “f”, “fl”, and “flutter”. Three requests may run. If “f” finishes last, the UI shows results for “f” even though the field contains “flutter”.

The handler needs a concurrency policy.

restartable: keep the latest request#

Search and filters usually care about the newest event:

Code
on<SearchQueryChanged>(
  _onQueryChanged,
  transformer: restartable(),
);

When a new SearchQueryChanged event arrives, restartable cancels the previous event handler subscription and starts the new one.

This is also useful for:

  • switching calendar months;
  • changing a selected category;
  • loading details for the latest selected item;
  • revalidating a username as it changes.

Cancellation does not necessarily stop the underlying HTTP request. It prevents an older handler from emitting stale state after a newer event has taken over. If the client supports request cancellation, that can be added separately.

Add debounce for text input#

Restarting on every keystroke still sends many requests. Debounce the event stream before applying restartable behavior:

Code
EventTransformer<E> debounceRestartable<E>(
  Duration duration,
) {
  return (events, mapper) {
    return restartable<E>().call(
      events.debounceTime(duration),
      mapper,
    );
  };
}

Then use it on the search event:

Code
on<SearchQueryChanged>(
  _onQueryChanged,
  transformer: debounceRestartable(
    const Duration(milliseconds: 400),
  ),
);

The request begins after typing pauses. If another query arrives later, the older handler cannot overwrite it.

The debounceTime operator here comes from RxDart. It solves timing; restartable solves overlap. They address related but different problems.

droppable: ignore duplicate work while busy#

Pagination often produces repeated events when a scroll listener remains near the bottom:

Code
on<NextPageRequested>(
  _onNextPageRequested,
  transformer: droppable(),
);

While one NextPageRequested handler is running, droppable ignores further events of that type. When the handler finishes, a later event can be processed.

This works well when:

  • the repeated event requests the same work;
  • the current request should finish;
  • starting a second request would duplicate data or cost.
Code
Future<void> _onNextPageRequested(
  NextPageRequested event,
  Emitter<ProductsState> emit,
) async {
  if (state.hasReachedEnd) return;

  final page = await repository.getProducts(
    offset: state.products.length,
  );

  emit(
    state.copyWith(
      products: [...state.products, ...page.items],
      hasReachedEnd: page.items.isEmpty,
    ),
  );
}

I also use this idea for initial dashboard requests that can be triggered by several rebuilds. The first request is useful; identical requests during that window are not.

Do not use droppable for every button. If the second event contains newer information, dropping it loses user intent.

sequential: preserve order#

Some operations must finish in the order they were received:

Code
on<CartQuantityChanged>(
  _onQuantityChanged,
  transformer: sequential(),
);

If a user changes a quantity from one to two and then from two to three, processing both writes concurrently may leave the server at two.

Sequential handling queues the second event until the first completes. It is useful for:

  • ordered writes;
  • local database migrations;
  • upload steps that depend on earlier steps;
  • commands where every event matters.

The tradeoff is waiting. A long-running event delays everything behind it, so avoid this policy when only the latest value matters.

concurrent: allow independent work#

Concurrent handling allows every event to run immediately:

Code
on<ThumbnailPrefetched>(
  _onThumbnailPrefetched,
  transformer: concurrent(),
);

Use it only when operations do not depend on order and cannot corrupt shared state. Independent prefetches may fit. Multiple updates to the same record usually do not.

Explicit concurrent is useful as documentation: reviewers can see that overlap was considered and accepted.

A practical decision table#

SituationTransformerReason
Search query changedrestartableOnly the newest query matters
Search while typingdebounce plus restartableWait briefly and keep the newest
Infinite-scroll page requestdroppableIgnore duplicate requests while loading
Submit buttondroppablePrevent duplicate submission
Ordered server updatessequentialPreserve every event in order
Independent prefetchesconcurrentWork can safely overlap

The event name alone does not decide the transformer. Ask what the second event means while the first is running.

Handle refresh and pagination separately#

Event transformers apply per registered event type. If refresh and next-page are separate event classes, they can still overlap.

You must decide how they relate:

Code
on<ProductsRefreshed>(
  _onRefreshed,
  transformer: restartable(),
);

on<NextPageRequested>(
  _onNextPageRequested,
  transformer: droppable(),
);

This prevents duplicates within each event type, but it does not automatically coordinate refresh with pagination. A state guard, shared operation token, or a single event hierarchy with a shared transformer may be needed.

That detail matters. Adding droppable to one handler does not make the whole BLoC race-free.

Test the overlap, not only one event#

A normal success test cannot prove a concurrency policy. Control request completion:

Code
test('latest search result wins', () async {
  final first = Completer<List<Product>>();
  final second = Completer<List<Product>>();

  when(() => repository.search('flu'))
      .thenAnswer((_) => first.future);
  when(() => repository.search('flutter'))
      .thenAnswer((_) => second.future);

  bloc
    ..add(const SearchQueryChanged('flu'))
    ..add(const SearchQueryChanged('flutter'));

  second.complete([flutterCourse]);
  first.complete([unrelatedCourse]);

  await expectLater(
    bloc.stream,
    emitsThrough(
      predicate<SearchState>(
        (state) => state.results.contains(flutterCourse),
      ),
    ),
  );

  expect(bloc.state.results, [flutterCourse]);
});

For droppable, dispatch the same event twice while the first dependency call is incomplete and verify that the repository is called once.

The package did not replace state design#

bloc_concurrency solved a class of bugs that loading flags alone did not solve. A loading flag can block work inside a handler, but it is easy to apply inconsistently and it does not express cancellation.

I still keep state guards such as hasReachedEnd because they protect business rules. The transformer controls event overlap; the handler controls whether work is valid.

Once I began treating concurrency as part of each event's design, pagination and search behavior became much easier to reason about. The key was not choosing one transformer for the app. It was choosing the correct meaning for the next event.

← Back to all writing