skip to content

In Dart, a for-in loop notifying a List of listeners throws ConcurrentModificationError when one listener unsubscribes itself; why, and how do you fix it?

level: seniorimportance: should knowfreq 34%

answer

  1. for-in drives an Iterator
  2. length change between moveNext calls
  3. values can change, length cannot
  4. iterate a copy or defer removal
  5. ChangeNotifier nulls the slot

basics

~10 s

A Dart for-in loop drives the list's Iterator, and changing a growable List's length between moveNext calls throws ConcurrentModificationError. Iterate a snapshot such as List.of(listeners), or defer removals until the loop ends.

solid answer

~30 s

`for-in` is sugar over `iterable.iterator` with `moveNext()` and `current`. The `List` contract says changing its length between iteration steps causes a `ConcurrentModificationError`, reported on the next `moveNext()`, so a listener that removes itself mid-notification breaks the loop, while replacing values in place would not. Fixes: iterate a copy with `List.of(_listeners)`, collect removals and apply them after the loop, use `removeWhere` for a predicate, or walk indices backwards with `removeAt`. For hot paths, do what Flutter's `ChangeNotifier` does: null the slot during notification and compact afterwards. `forEach` has the same problem and cannot `break`, so prefer `for-in`.

code

dart · 34 lines
dart
typedef Listener = void Function();

class EventBus {
  final _listeners = <Listener>[];

  void subscribe(Listener l) => _listeners.add(l);
  void unsubscribe(Listener l) => _listeners.remove(l);

  void emitBroken() {
    for (final l in _listeners) {
      l(); // a listener that unsubscribes itself changes the length
    }
  }

  void emit() {
    for (final l in List.of(_listeners)) {
      l(); // iterating a snapshot: mutation of _listeners is safe
    }
  }
}

void main() {
  final bus = EventBus();
  late final Listener once;
  once = () {
    print('fired once');
    bus.unsubscribe(once);
  };
  bus
    ..subscribe(once)
    ..subscribe(() => print('always'));
  bus.emit(); // fired once, always
  bus.emit(); // always
}

go deeper

for a junior

Recall that for-in works on any Iterable and that adding or removing elements while looping over a List is not allowed.

for a middle

Explain the Iterator protocol behind for-in and that length changes, not value changes, trigger ConcurrentModificationError on the next moveNext.

for a senior

Diagnose the re-entrant listener bug from the stack trace, pick between snapshot, deferred removal, backwards index loop and tombstones, and pin it with a regression test.

for a principal

Weigh the cost of copying on every notification against tombstone bookkeeping in hot paths, and make mutation ownership an explicit rule in shared event APIs.

## What a for-in loop really does `for (final x in iterable) { ... }` works on **any `Iterable`**: a `List`, a `Set`, `map.keys`, `map.entries`, or a lazy `where`/`map` result. It is shorthand for driving an **`Iterator`**: ```dart final it = iterable.iterator; while (it.moveNext()) { final x = it.current; // body } ``` A `Map` is not itself an `Iterable`, so `for (x in map)` does not compile; iterate `map.keys`, `map.values` or `map.entries`. Reassigning the loop variable only changes that local; it never writes back into the collection. ## The production bug A common shape: a small event bus keeps a `List<void Function()>` of listeners and notifies them in a `for-in` loop. One listener, when called, **unsubscribes itself**, or subscribes a new listener. The next call to `moveNext()` throws: ``` Concurrent modification during iteration: Instance(length:1) of '_GrowableList'. ``` The SDK documents the rule on `List`: iteration happens in index order; changing values is fine, but **changing the list's length between iteration steps causes a `ConcurrentModificationError`**. The same applies to `Set` and `Map` views: changing a collection while it is being iterated is generally not allowed and is typically signalled on the next `moveNext()`. The iterator does not guarantee detection, either; the `List` docs note that a length change that is restored before the next step might go unnoticed, which is why the bug can be intermittent. ## Fixes, and when to use each | Fix | How | Good for | |---|---|---| | **Iterate a snapshot** | `for (final l in List.of(_listeners))` | callbacks that may add or remove listeners | | **Collect, then mutate** | gather items to remove, then `removeWhere` or `removeAll` after the loop | filtering expired entries | | **Use a bulk method** | `list.removeWhere((e) => e.isExpired)` | a simple predicate, no loop at all | | **Index loop, backwards** | `for (var i = list.length - 1; i >= 0; i--)` then `removeAt(i)` | in-place removal without allocation | | **Tombstones** | set the slot to `null`, compact after the loop | hot paths that notify often | Two notes on these: - A **forward** index loop with `removeAt(i)` does not throw, because no iterator is involved, but it **skips** the element that slides into index `i`. Backwards iteration avoids that. - The tombstone approach is what Flutter's `ChangeNotifier` does: during `notifyListeners`, `removeListener` sets the listener's slot to `null` and counts it, and the list is compacted once notification finishes. It is a good model when copying on every notification is too costly. ## `forEach` has the same problem, and more `list.forEach((x) { ... })` iterates the same way, so mutating the list inside the callback fails too. It also cannot be exited early with `break`, and a `return` only ends one callback call. Effective Dart asks you to **avoid `forEach` with a function literal** and write a `for-in` loop instead; the `avoid_function_literals_in_foreach_calls` lint is in the `recommended` and `flutter` sets. ## Diagnosing it in practice 1. **Read the stack trace**: the error is thrown from the iterator's `moveNext`, so the top frames point at the loop, not at the code that mutated the list. Look for what the loop body calls. 2. **Search for re-entrancy**: callbacks, listeners and event handlers invoked from the loop are the usual culprits. 3. **Decide who owns mutation**: either the loop works on a snapshot, or mutations are deferred until the loop ends. 4. **Add a test** that subscribes a listener which unsubscribes itself, so the regression cannot return. ## What an interviewer listens for - That `for-in` drives an `Iterator` and the error surfaces on the next `moveNext()`. - That changing **length** is the problem, while replacing values in place is fine. - At least two fixes with their trade-offs, including the snapshot and the deferred removal. - Awareness that `forEach` shares the issue and cannot `break`.

  • Why does removing items with a forward index loop in Dart not throw, and what goes wrong instead?
    An index loop never creates an `Iterator`, so there is no concurrent-modification check. But `removeAt(i)` shifts every later element down by one, so the element that moves into index `i` is skipped when `i++` runs. Iterate from `length - 1` down to `0`, or use `removeWhere` with a predicate.
  • How does Flutter's ChangeNotifier let a listener remove itself during notifyListeners?
    While a notification is in progress, `removeListener` does not shrink the internal list; it sets that listener's slot to `null` and counts the removal. Notification skips `null` slots, and once the outermost `notifyListeners` call finishes, the list is compacted. That avoids both the error and a copy on every notification.

saying these in an interview costs you the question

  • Thinks changing a value at an index also throws
  • Believes forEach avoids concurrent modification problems
  • Removes items in a forward index loop without adjusting i
  • Says Dart iterators always detect every modification
  • Tries to iterate a Map directly with for-in