With package:bloc, why does a handler trigger the assertion 'emit was called after an event handler completed normally', and how do you fix it?
answer
- the handler's Future already finished
- then callback or unawaited helper
- make it async and await
- cancelled runs differ: silent ignore
- debug assert, release behaves differently
basics
~20 sThe handler's Future completed while work it started was still pending, typically a then callback, an unawaited helper or a Timer, and that work later called emit. Make the handler async and await everything that emits, or return its Future.
solid answer
~40 sbloc tracks each handler run through its `Emitter`. When the handler's returned Future completes, bloc marks that Emitter completed; an `emit` afterwards trips a debug assertion whose message names the usual cause, an unawaited future. Typical offenders: `_cities.search(q).then((r) => emit(...))` in a non-async handler, a `Timer` or `Future.delayed` callback, a `stream.listen` inside the handler, or an emitting helper called without `await`. Fix it by marking the handler `async` and awaiting every operation that emits (or returning its Future), using `emit.forEach` for streams, and moving timing into a transformer. The assertion runs only in debug builds; in release the late emit is not blocked and can land out of order after later events' states. A cancelled run is different: after `restartable()` or `close()`, emit is silently ignored and `emit.isDone` is `true`.
code
dart · 23 linesimport 'package:bloc/bloc.dart';
class CitySearchBloc extends Bloc<CitySearchEvent, CitySearchState> {
CitySearchBloc(this._cities) : super(CitySearchInitial()) {
on<CitySearchQueryChanged>(_onQueryChanged);
}
final CityRepository _cities;
Future<void> _onQueryChanged(
CitySearchQueryChanged event,
Emitter<CitySearchState> emit,
) async {
emit(CitySearchInProgress());
await _loadResults(event.query, emit); // awaited: the run covers the helper
}
Future<void> _loadResults(String query, Emitter<CitySearchState> emit) async {
final cities = await _cities.search(query);
if (emit.isDone) return;
emit(CitySearchSuccess(cities));
}
}go deeper
Recall the rule: a handler that emits after an async call must be async and await that call.
Explain completed versus cancelled Emitters, what the handler's Future represents, and the common offenders such as then callbacks and unawaited helpers.
Show why this is a production ordering bug in release builds, and how it silently breaks droppable and sequential transformers, not just a debug nuisance.
Push for lint and review rules against unawaited futures in handlers so the class of bug is prevented rather than debugged.
## How bloc tracks a handler run In **bloc** 9.2, every event that reaches a handler gets its own `Emitter`, and bloc wraps the call roughly like this: - it awaits `handler(event, emitter)`; - when that Future completes, it calls `onDone` and **completes the Emitter**; - if the handler throws, it calls `onError` and `onDone` with the error, completes the Emitter and rethrows. An Emitter can end in two ways, and `emit.isDone` is `true` after either: | End state | Caused by | Later `emit` call | |---|---|---| | **completed** | the handler's Future finished | debug assertion; not blocked in release | | **cancelled** | `close()`, or a transformer such as `restartable()` | silently ignored | ## Why the assertion fires 1. A handler starts asynchronous work but does not tie its own Future to it. 2. The handler returns; its Future completes; bloc completes the Emitter. 3. The work finishes later and calls `emit`. 4. `emit` asserts the Emitter is not completed and fails with: emit was called after an event handler completed normally, usually due to an unawaited future. ## Common offenders | Code in the handler | Why it escapes | |---|---| | `repo.search(q).then((r) => emit(...))` without `await` | `then` schedules the emit; the handler returns immediately | | `Timer(duration, () => emit(...))` | the timer outlives the handler | | `stream.listen((v) => emit(...))` | the subscription outlives the handler | | `_loadDetails(emit);` where `_loadDetails` is `async` | the helper's Future is dropped | | `unawaited(...)` around emitting work | explicitly detached | ## Fixes ```dart // Bad: the handler completes before the search does. on<CitySearchQueryChanged>((event, emit) { _cities.search(event.query).then((cities) => emit(CitySearchSuccess(cities))); }); // Good: the handler's Future covers the whole run. on<CitySearchQueryChanged>((event, emit) async { final cities = await _cities.search(event.query); emit(CitySearchSuccess(cities)); }); ``` - Make the handler `async` and `await` every call that eventually emits, including private helpers that take the `emit` parameter. - For streams, `return emit.forEach(...)` or `await emit.onEach(...)` instead of `listen`. - For delays and rate limits, use an `EventTransformer` (debounce, throttle) rather than a timer inside the handler. - Where a run can be cancelled, check `emit.isDone` after awaits to skip wasted work. ## Debug versus release The check is an `assert`, so it only runs in debug builds (and tests). In release the late `emit` is not stopped by the Emitter — only a cancelled Emitter or a closed Bloc blocks it — so the stale state can land **after** states produced by later events. That is why the assertion matters: the bug it catches in development is an ordering bug in production. ## The sibling assertion A closely related debug assertion fires when a handler completes while an `emit.forEach` or `emit.onEach` subscription is still open: the handler completed but left pending subscriptions behind. Same cause (an unawaited Future), same fix (await or return it). Treat both as bugs to fix, never as noise to suppress.
- Why doesn't a restartable cancellation trigger the same assertion?Cancellation marks the Emitter cancelled, not completed. emit checks the completed flag in its assertion and the cancelled flag for its behaviour, so a cancelled run's emit is dropped quietly. That is intended: a superseded search result is expected, not a bug.
- Could you silence the assertion by checking emit.isDone before every emit?It would stop the assertion, but it hides the real defect: the handler still returns before its work finishes, so onDone fires early, transformers think the run is over and droppable or sequential ordering breaks. Fix the missing await instead.
saying these in an interview costs you the question
- The assertion means the Bloc was already closed
- Wrapping emit in try/catch is an acceptable fix
- A then callback inside the handler is fine because emit is still in scope
- The same late emit is also dropped in release builds
- A restartable-cancelled run triggers this same assertion