In my online multiplayer game, the opponent’s moves stopped appearing on the board, and the screen froze with one second left on the turn timer. The cause was that I stored moves in Firebase Realtime Database under sequential keys like moves/0 and moves/1.
The short answer: Firebase Realtime Database returns data as an array instead of an object when all keys are integers and more than half of the slots from 0 to the largest key have values. If your Flutter code casts that value with as Map, it throws.
This article shows the exact conditions I confirmed on the emulator, the Dart exception you get, and three ways to fix it.
Sponsored
When does Firebase Realtime Database return an array?
When every key is an integer and more than half of the positions between 0 and the largest key hold values. A single string key keeps the data as an object.
The Firebase blog post Best Practices: Arrays in Firebase describes the rule: data is rendered as an array if all of the keys are integers and more than half of the keys between 0 and the maximum key have non-empty values.
I tested this with the Realtime Database in Firebase Local Emulator Suite (firebase-tools 15.16.0) and Firebase JS SDK 12.19.0.
| Keys I saved | Value read back |
|---|---|
0, 1, 2 (each written with a separate set) |
Array [{"b":4,"c":2},{"b":2,"c":7},{"b":7,"c":0}] |
0, 5 (2 of 6 positions) |
Object {"0":{...},"5":{...}} |
0, 1, 3 (3 of 4 positions) |
Array [{...},{...},null,{...}] |
m0, m1 (string keys) |
Object {"m0":{...},"m1":{...}} |
I wrote 0, 1 and 2 one at a time, yet they came back as an array. What decides the shape is the keys at read time, not whether you saved an array. Reading the same path through the REST API also returned [{"b":4,"c":2},{"b":2,"c":7},{"b":7,"c":0}].
When a key is missing in the middle, as with 0, 1 and 3, the array contains null at the missing position.
Why does “as Map” throw in Flutter?
The snapshot value arrives as a List, so casting it to Map fails with a type error.
My game’s listener read the move list like this:
final data = event.snapshot.value as Map;
final moves = data['moves'] as Map; // throws here
Reproducing the same value in Dart 3.11.5 gave this exception:
type 'List<Object?>' is not a subtype of type 'Map<dynamic, dynamic>' in type cast
Because the exception happens inside the onValue handler, the handler aborts halfway every time the data changes. In my game, that showed up as three symptoms.
▼Symptoms I actually saw
▶ None of the opponent’s moves appeared on the board
▶ The turn timer was never re-armed and froze at about one second left
▶ Exceptions piled up until the app crashed
None of these symptoms pointed at the code that parses moves. They looked like rendering or timer bugs, which is why it took a long time to find the cause.
Sponsored
Fix 1: How do you parse both List and Map safely?
Write one parser that accepts either a List or a Map, and run every read of that data through it.
List<Move> parseMoves(Object? raw) {
final entries = <int, Map>{};
if (raw is List) {
for (var i = 0; i < raw.length; i++) {
final v = raw[i];
if (v is Map) entries[i] = v; // skip nulls at missing positions
}
} else if (raw is Map) {
raw.forEach((k, v) {
final index = int.tryParse(k.toString());
if (index != null && v is Map) entries[index] = v;
});
}
final keys = entries.keys.toList()..sort();
return [
for (final k in keys)
Move((entries[k]!['b'] as num).toInt(), (entries[k]!['c'] as num).toInt()),
];
}
The key points are skipping null for a List, and converting keys to integers and sorting them for a Map. Whether the data comes back as an array or an object, and even with a gap like 0, 1, 3, you get the same ordered move list.
Fix 2: Which keys avoid the array conversion?
If you never want an array, save the data under keys that are not integers.
As the emulator test showed, keys that contain letters, such as m0 and m1, stay an object when read back. If you need ordering, you can also use the keys generated by push(). The Firebase blog also warns that array-like data is only safe when a single client writes it at a time.
| Key style | Good for | Watch out for |
|---|---|---|
Sequential integers (0, 1, …) |
When you cannot change the existing data structure | Accept both List and Map when reading (Fix 1) |
Keys with letters (m0, m1, …) |
Numbered data such as game moves | Keys sort as strings, so sort on the client |
Auto keys from push() |
Appending from several devices | Not numbers, so store the move number inside the value |
For my game, I chose Fix 1 so that the move data format for online matches could stay the same.
Sponsored
Fix 3: How do you keep one bad value from stopping the listener?
Wrap the whole onValue handler in try/catch, so a single exception does not skip the rest of the processing.
This bug grew into “not reflected”, “frozen” and “crashed” because one type error skipped everything after it in the handler.
_sub = roomRef.onValue.listen((event) {
try {
final data = event.snapshot.value;
if (data is! Map) return;
final moves = parseMoves(data['moves']);
applyMoves(moves);
} catch (e) {
// Even if one value is broken, keep receiving the next update
debugPrint('[online] onValue failed: $e');
}
});
Always log the error, because swallowing it hides the cause. If you need to investigate release builds, sending it to a tool like Crashlytics as a non-fatal error lets you see what happened on users’ devices later.
Related articles
▶ Ultimate Tic Tac Toe: Rules and How to Actually Win…the rules of the online game where this bug happened
▶ Ultimate Tic Tac Toe (究極マルバツ) app support page…the app that implements the online matches
Summary
▼Key points
① Realtime Database returns an array when all keys are integers and more than half the positions are filled
② Missing positions show up as null inside the array
③ Casting with as Map in Flutter causes a List<Object?> type error
④ Accept both List and Map when reading, or save with non-integer keys
⑤ Wrap onValue handlers in try/catch so one exception does not stop them
The code that saved the moves never used an array, yet the data came back as one. That asymmetry took the longest to notice. If you use sequential integer keys, write your parser from the start on the assumption that an array may come back.
Tested with Firebase Local Emulator Suite (firebase-tools 15.16.0), Firebase JS SDK 12.19.0 and Dart 3.11.5 on September 12, 2026.