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:
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:
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:
EventTransformer<E> debounceRestartable<E>(
Duration duration,
) {
return (events, mapper) {
return restartable<E>().call(
events.debounceTime(duration),
mapper,
);
};
}
Then use it on the search event:
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:
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.
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:
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:
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#
| Situation | Transformer | Reason |
|---|---|---|
| Search query changed | restartable | Only the newest query matters |
| Search while typing | debounce plus restartable | Wait briefly and keep the newest |
| Infinite-scroll page request | droppable | Ignore duplicate requests while loading |
| Submit button | droppable | Prevent duplicate submission |
| Ordered server updates | sequential | Preserve every event in order |
| Independent prefetches | concurrent | Work 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:
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:
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.