Welcome to Part 12 of the Flutter Interview Questions 2025 series! This is where interviews get truly intense. We are covering four sections that test the depth of your Dart and Flutter knowledge: Dart language gotchas (List reference traps, == vs identical(), late crashes), architecture dilemmas (BLoC vs Riverpod for a chat app, offline-first sync strategies), debugging scenarios (release-mode-only crashes, setState but UI stays stale), and predict-the-output code puzzles that expose your mental model of the event loop, widget lifecycle, and async timing. This is part 12 of our comprehensive 14-part series. Bookmark it and come back whenever you need a refresher before your next interview.
What's in this part?
-
Section 1: Dart Language Tricky Questions (18 Qs) — List reference traps,
==vsidentical(),finalvsconstLists,latecrashes,dynamicvsObjectvsvar, factory constructors, extension conflicts, sealed classes, mixin class, and more - Section 2: Architecture & Design Dilemmas (10 Qs) — BLoC vs Riverpod for chat, offline-first sync, token refresh interceptors, shared state across screens, deep linking with back stacks, team project structure, API versioning, Method Channel vs FFI, federated plugins, app size optimization
-
Section 3: Debugging & "What Went Wrong?" Scenarios (16 Qs) — Release-only crashes,
setStatebut no UI update, hot reload stale UI, RenderFlex overflow, disposed widget errors, first-launch slowness, blurry images, deactivated widget ancestors, iOS-only HTTP failures, keyboard overflow, unbounded viewport, Firebase background notifications, emulator-vs-device crashes, BLoC double-fire,context.readvscontext.watch, scroll performance - Section 4: Predict the Output (12 Qs) — FutureBuilder rebuilding, setState with async gaps, Key behavior with reordering, broadcast vs single-subscription streams, Provider notification timing, widget lifecycle ordering, null safety edge cases, isolate message passing, AnimationController states, BuildContext availability, event loop ordering, try-catch with Futures
SECTION 1: DART LANGUAGE TRICKY QUESTIONS
Q1: What's the output? List a = [1,2,3]; List b = a; b.add(4); print(a);
What the interviewer is REALLY testing:
Do you understand reference types vs value types in Dart? Do you know that assigning a List to another variable does NOT create a copy?
Answer:
Output: [1, 2, 3, 4]
In Dart, Lists (and all objects) are reference types. When you write List b = a, you are NOT copying the list. You are copying the reference — both a and b now point to the exact same list object in memory. Any mutation through b is visible through a because they are the same object.
List a = [1, 2, 3];
List b = a; // b points to the SAME list object as a
b.add(4);
print(a); // [1, 2, 3, 4]
print(identical(a, b)); // true — same object in memory
How to actually copy a list:
// Shallow copy approaches:
List b = List.from(a);
List b = [...a];
List b = a.toList();
// Deep copy (for nested objects):
List<List<int>> a = [[1,2], [3,4]];
List<List<int>> b = a.map((inner) => List<int>.from(inner)).toList();
Follow-up trap: Even List.from is a shallow copy. If the list contains objects, you still share references to those inner objects.
Q2: What's the difference between == and identical() in Dart?
What the interviewer is REALLY testing:
Do you understand structural equality vs referential identity? Do you know which one == uses by default and when the framework overrides it?
Answer:
| Aspect | == |
identical() |
|---|---|---|
| What it checks | Equality (can be overridden) | Identity — same object in memory |
| Can be overridden | Yes, via operator ==
|
No, it is a top-level function |
| Default behavior | Identity check (same as identical) |
Always identity check |
class Point {
final int x, y;
Point(this.x, this.y);
}
var p1 = Point(1, 2);
var p2 = Point(1, 2);
print(p1 == p2); // false — default == checks identity
print(identical(p1, p2)); // false — different objects
class Point {
final int x, y;
Point(this.x, this.y);
@override
bool operator ==(Object other) =>
other is Point && other.x == x && other.y == y;
@override
int get hashCode => Object.hash(x, y);
}
var p1 = Point(1, 2);
var p2 = Point(1, 2);
print(p1 == p2); // true — overridden == checks fields
print(identical(p1, p2)); // false — still different objects in memory
Key Dart special cases:
- Integers:
identical(1, 1)istrue(small integers are canonicalized). - Strings:
identical("hello", "hello")istrue(compile-time string literals are interned). -
constobjects:identical(const Point(1,2), const Point(1,2))istrue(const canonicalization).
Q3: Why does "hello" == "hello" return true but MyClass() == MyClass() returns false?
What the interviewer is REALLY testing:
Do you understand that String overrides == to compare contents, while custom classes use the default Object.== which checks identity?
Answer:
Dart's String class overrides operator == to compare the actual character sequences. So two different String objects with the same content are considered equal.
Your custom class does not override operator == by default. It inherits Object.operator ==, which checks identity — are these the exact same object in memory? Two fresh instances are never identical.
print("hello" == "hello"); // true — String.== compares contents
print(MyClass() == MyClass()); // false — Object.== compares identity
// What String.== effectively does:
// bool operator ==(Object other) => other is String && this.compareTo(other) == 0;
Bonus insight: Dart string literals are also interned at compile time, so identical("hello", "hello") is also true. But that is a separate mechanism from == — it is the VM reusing the same object, not an equality override.
Q4: What happens if you override == but not hashCode?
What the interviewer is REALLY testing:
Do you understand the contract between == and hashCode? Do you know the real-world consequences in Sets and Maps?
Answer:
The contract: If a == b is true, then a.hashCode must equal b.hashCode. Violating this breaks every hash-based collection.
class User {
final String email;
User(this.email);
@override
bool operator ==(Object other) =>
other is User && other.email == email;
// FORGOT to override hashCode!
}
void main() {
var set = <User>{};
set.add(User("a@b.com"));
set.add(User("a@b.com"));
print(set.length); // 2!!! — should be 1 if hashCode was correct
// The set uses hashCode first to find the bucket.
// Different hashCodes → different buckets → never even calls ==.
var map = <User, String>{};
map[User("a@b.com")] = "Alice";
print(map[User("a@b.com")]); // null!!! — can't find the key
}
The mechanism:
-
HashSet/HashMapcallhashCodefirst to locate a bucket. - Only if two objects land in the same bucket does it call
==. - Default
hashCodeis based on identity (memory address), so two "equal" objects get different hash codes → different buckets →==is never called.
The Dart analyzer (linter) will warn you: hash_and_equals lint rule catches this.
Q5: Can you modify a final List? What about a const List? Why?
What the interviewer is REALLY testing:
Do you understand the difference between a final reference and an immutable object? This is one of the most commonly confused concepts.
Answer:
// final — the REFERENCE cannot be reassigned, but the OBJECT can be mutated.
final List<int> a = [1, 2, 3];
a.add(4); // WORKS — mutating the list is fine
// a = [5, 6]; // COMPILE ERROR — can't reassign the variable
// const — the OBJECT itself is deeply immutable.
const List<int> b = [1, 2, 3];
// b.add(4); // RUNTIME ERROR — Unsupported operation: Cannot add to an unmodifiable list
// b = [5, 6]; // COMPILE ERROR — also can't reassign
// This is also immutable:
final List<int> c = const [1, 2, 3];
// c.add(4); // RUNTIME ERROR — the list object is const even though the variable is final
The mental model:
-
final= "This variable name is permanently bound to this one object." The object itself has no restrictions. -
const= "This object was created at compile time and is permanently frozen." Cannot be mutated. Period.
Trap question follow-up: What about final List<int> x = [];? This is a mutable growable list with a final reference. You can add, remove, clear — just can't do x = anotherList.
Q6: What's the output? int? x; print(x ?? 0); x ??= 5; print(x);
What the interviewer is REALLY testing:
Do you understand null-aware operators, and the subtle difference between ?? (evaluate) and ??= (assign)?
Answer:
Output:
0
5
Step-by-step:
int? x; // x is null (default for nullable types)
print(x ?? 0); // x is null, so ?? returns 0. x itself is STILL null.
x ??= 5; // x is null, so ??= assigns 5 to x. Now x == 5.
print(x); // 5
Key distinction:
-
x ?? 0— evaluates to0but does not changex. It is an expression, not an assignment. -
x ??= 5— assigns5toxonly ifxis currentlynull. It is an assignment operator.
Follow-up trap:
int? x = 3;
print(x ?? 0); // 3 — x is not null, so ?? returns x
x ??= 5; // x is not null, so ??= does NOTHING
print(x); // 3
Q7: What happens with late String name; if you access it before assigning?
What the interviewer is REALLY testing:
Do you understand late — that it moves the null-safety check from compile time to runtime? Do you know the exact error?
Answer:
You get a LateInitializationError at runtime.
class User {
late String name;
}
void main() {
var u = User();
print(u.name); // LateInitializationError: Field 'name' has not been initialized.
}
Why late exists:
- You are telling the compiler: "Trust me, this will be assigned before it is read."
- The compiler removes the nullable check and treats
nameas non-nullableString. - But at runtime, Dart inserts a hidden sentinel value. On access, it checks if the sentinel is still there and throws if so.
Critical detail — late fields are lazily initialized if given an initializer:
late String name = expensiveComputation(); // NOT called until first read
This is different from:
String name = expensiveComputation(); // Called immediately at construction
Q8: Explain — why does this code compile but crash at runtime?
class Service {
late final String token;
void init() {
token = fetchToken();
}
String get authHeader => 'Bearer $token';
}
void main() {
var s = Service();
print(s.authHeader); // CRASH
}
What the interviewer is REALLY testing:
Can you identify temporal coupling bugs? Do you understand that late shifts null safety from compile time to runtime — effectively re-introducing the null reference problem?
Answer:
The code compiles because:
-
tokenis declaredlate final String— non-nullable, so the compiler trusts it will be assigned. -
authHeaderusestoken— the compiler sees a non-nullableString, no warnings. -
init()assignstoken— everything looks structurally correct.
It crashes because:
-
main()createsServicebut never callsinit(). -
authHeaderaccessestokenbeforeinit()runs. - Runtime throws
LateInitializationError.
The lesson: late is a promise to the compiler. If you break the promise, you get a runtime crash. This is the Dart equivalent of a null pointer exception — late does not eliminate null safety problems, it just moves them.
How to fix:
// Option 1: Require token in constructor
class Service {
final String token;
Service(this.token);
String get authHeader => 'Bearer $token';
}
// Option 2: Make it nullable and handle it
class Service {
String? token;
void init() { token = fetchToken(); }
String get authHeader => 'Bearer ${token ?? "NO_TOKEN"}';
}
// Option 3: Use late with an initializer (if fetchToken is synchronous)
class Service {
late final String token = fetchToken(); // lazy, called on first access
String get authHeader => 'Bearer $token';
}
Q9: What's the difference between dynamic, Object, and var?
What the interviewer is REALLY testing:
Do you understand Dart's type system at a fundamental level? This is the question that separates people who read the docs from people who understand the language.
Answer:
| Feature | dynamic |
Object / Object?
|
var |
|---|---|---|---|
| Type checking | None — disables static analysis | Full — only Object members available | Full — inferred type |
| Can call any method | Yes (fails at runtime if wrong) | No (compile error) | No (compile error) |
| Type known at | Runtime only | Compile time (Object) |
Compile time (inferred) |
| Is it a type? | Yes (special type) | Yes (root of class hierarchy) | No — it is a keyword for inference |
dynamic a = "hello";
a.foo(); // Compiles fine. Crashes at runtime — NoSuchMethodError.
a.length; // Compiles fine. Works at runtime (String has length).
a = 42; // Fine — dynamic accepts anything.
Object b = "hello";
// b.length; // COMPILE ERROR — Object has no .length
(b as String).length; // Works — explicit cast
b = 42; // Fine — int is an Object.
var c = "hello"; // c is inferred as String
c.length; // Works — compiler knows c is String
// c = 42; // COMPILE ERROR — int is not String
The critical insight:
-
dynamicturns off the type system. It is an escape hatch. Your code compiles but can crash at runtime. -
Objectkeeps the type system on. You cannot call methods that aren't onObjectwithout casting. -
varis just shorthand — the compiler infers the real type and enforces it fully.
When to use each:
-
dynamic: Almost never. JSON decoding, interop, or when you truly do not know the type. -
Object: When you need to hold "any object" but still want type safety. -
var: Default choice for local variables when the type is obvious from the right side.
Q10: Can a factory constructor return null? Can a generative constructor?
What the interviewer is REALLY testing:
Do you understand the fundamental difference between factory and generative constructors? Do you know what a generative constructor is obligated to return?
Answer:
Generative constructor: Cannot return null. It always creates a new instance of the class (or throws). You cannot even write a return statement in a generative constructor body.
Factory constructor: It depends on the return type.
class Connection {
// Generative — ALWAYS returns a new Connection. No return statement allowed.
Connection();
// Factory — returns Connection. Cannot return null because return type is non-nullable.
factory Connection.create() {
return Connection();
}
// Factory — CAN return null if the return type is nullable.
// BUT: Dart does not allow a constructor to have a nullable return type.
// So the answer is: factory constructors CANNOT return null either.
}
The real answer is: neither can return null. Dart constructors always have a non-nullable return type matching the class. There is no syntax for factory MyClass?.
However, a factory constructor CAN:
- Return an existing cached instance (singleton pattern).
- Return a subtype (polymorphic construction).
- Throw an exception instead of returning.
class Logger {
static final Logger _instance = Logger._internal();
// Returns the SAME instance every time — not null, but not new either.
factory Logger() => _instance;
Logger._internal();
}
// Subtypes:
abstract class Shape {
factory Shape(String type) {
if (type == 'circle') return Circle();
if (type == 'square') return Square();
throw ArgumentError('Unknown shape: $type');
}
}
class Circle implements Shape {}
class Square implements Shape {}
Q11: What happens if extension methods conflict?
What the interviewer is REALLY testing:
Do you know how Dart resolves ambiguity with extensions? This reveals whether you have actually used extensions in a non-trivial codebase.
Answer:
If two extensions define the same method on the same type and both are imported, you get a compile-time error — Dart will not silently pick one.
// file: string_ext_a.dart
extension StringExtA on String {
String get shout => toUpperCase() + '!!!';
}
// file: string_ext_b.dart
extension StringExtB on String {
String get shout => toUpperCase() + '???';
}
// file: main.dart
import 'string_ext_a.dart';
import 'string_ext_b.dart';
void main() {
print("hello".shout); // COMPILE ERROR: ambiguous
}
Resolution strategies:
// Strategy 1: Hide one
import 'string_ext_a.dart';
import 'string_ext_b.dart' hide StringExtB;
// Strategy 2: Use explicit extension application
import 'string_ext_a.dart';
import 'string_ext_b.dart';
void main() {
print(StringExtA("hello").shout); // Explicit — no ambiguity
print(StringExtB("hello").shout);
}
// Strategy 3: Use show to be selective
import 'string_ext_a.dart' show StringExtA;
Priority rules when there is no ambiguity:
- Instance methods always win over extension methods.
- A more specific type extension wins:
extension on intbeatsextension on numfor anint. - If specificity is equal, you must disambiguate manually.
Q12: How do sealed classes work and why are they useful with pattern matching?
What the interviewer is REALLY testing:
Do you understand Dart 3's sealed classes, exhaustiveness checking, and algebraic data types? This is the modern Dart question.
Answer:
A sealed class:
- Cannot be instantiated directly (abstract by nature).
-
Can only be extended or implemented in the same library (same file or same
librarydeclaration). - The compiler knows all possible subtypes at compile time → enables exhaustiveness checking in
switch.
// All subtypes must be in the same file/library.
sealed class AuthState {}
class Authenticated extends AuthState {
final String username;
Authenticated(this.username);
}
class Unauthenticated extends AuthState {}
class Loading extends AuthState {}
// Exhaustive switch — compiler GUARANTEES you handle all cases.
String describe(AuthState state) {
return switch (state) {
Authenticated(username: var name) => 'Welcome, $name',
Unauthenticated() => 'Please log in',
Loading() => 'Loading...',
// No default needed! Compiler knows this is exhaustive.
// If you add a new subtype, every switch breaks at compile time.
};
}
Why this is powerful:
- If a developer adds
class Error extends AuthState {}, everyswitchonAuthStatein the codebase will produce a compile error until it handlesError. - This is impossible with regular abstract classes — the compiler cannot know all subtypes, so
switchrequires adefaultor_wildcard. - This is the Dart equivalent of Kotlin's
sealed class, Rust'senum, or Swift'senumwith associated values.
Sealed vs abstract vs final vs interface:
| Modifier | Extend outside lib? | Implement outside lib? | Instantiate? | Exhaustive switch? |
|---|---|---|---|---|
sealed |
No | No | No | Yes |
abstract |
Yes | Yes | No | No |
final |
No | No | Yes | No |
interface |
No | Yes | Yes | No |
Q13: What's the difference between mixin and mixin class in Dart 3?
What the interviewer is REALLY testing:
Do you understand the Dart 3 class modifiers? Can you explain why mixin class exists?
Answer:
Before Dart 3, a regular class could be used as a mixin with with. Dart 3 restricted this — now only declarations marked mixin can be used in a with clause.
mixin class is the bridge — it can be used both as a regular class (extended, instantiated) and as a mixin (used with with).
// Pure mixin — cannot be instantiated or extended normally.
mixin Logging {
void log(String msg) => print('[LOG] $msg');
}
// class MyLogger extends Logging {} // ERROR — can't extend a mixin
// var l = Logging(); // ERROR — can't instantiate a mixin
class MyService with Logging {} // OK — used as mixin
// Mixin class — can be BOTH extended AND mixed in.
mixin class Validator {
bool isValid(String input) => input.isNotEmpty;
}
class MyValidator extends Validator {} // OK — used as class
class MyForm with Validator {} // OK — used as mixin
var v = Validator(); // OK — instantiated
Restrictions on mixin (and mixin class):
- Cannot have a
mixinthat extends anything other thanObject. - A
mixin classmust have only a zero-argument constructor (no required params) to be mixable.
When to use which:
-
mixin— You only want to provide reusable behavior to be mixed in. No direct instantiation needed. -
mixin class— You want a class that can serve double duty. Common for utility classes that provide behavior but also make sense standalone. -
class— Normal usage. Cannot be used withwithin Dart 3.
Q14: Can you have a constant Map with computed values? Why or why not?
What the interviewer is REALLY testing:
Do you understand compile-time constants in Dart? Do you know what the compiler can and cannot evaluate?
Answer:
No. const requires that every value (and key) be a compile-time constant. Computed values from function calls, runtime variables, or anything the compiler cannot evaluate at compile time cannot be const.
// VALID — all values are compile-time constants
const map1 = {
'key1': 'value1',
'key2': 42,
'key3': true,
'key4': 3 + 4, // Constant expression — compiler evaluates to 7
'key5': 'hello' + ' world', // Constant string concatenation — OK
};
// INVALID — function calls are not compile-time constants
const map2 = {
'now': DateTime.now(), // ERROR — DateTime.now() is runtime
'random': Random().nextInt(10), // ERROR
};
// INVALID — non-const variables
final x = 42;
const map3 = {'key': x}; // ERROR — x is final, not const
// VALID — const variables work
const x = 42;
const map3 = {'key': x}; // OK
What counts as a compile-time constant:
- Numeric, string, bool, null literals
-
constconstructors withconstarguments - Arithmetic on constants (
3 + 4) - String interpolation with constants (
'${constVar}') - Ternary expressions on constants (
constBool ? 1 : 2) -
identical()on constants
What does NOT count:
- Any function call (except
constconstructors andidentical) - Accessing
finalvariables (they are runtime) -
DateTime.now(),Random(), any I/O
Q15: What's the difference between these two?
Future<void> foo() async { return; }
Future<void> bar() { return Future.value(); }
What the interviewer is REALLY testing:
Do you understand the async machinery? Do you know that async implicitly wraps the return in a Future and schedules it as a microtask?
Answer:
Both return a Future<void>, but the timing differs.
Future<void> foo() async {
return; // Body runs synchronously up to first await (there is none).
// But the return value is STILL wrapped in a Future and delivered asynchronously.
}
Future<void> bar() {
return Future.value(); // Returns an already-completed Future, also delivered asynchronously.
}
The critical difference — error handling:
Future<void> foo() async {
throw Exception('boom');
// The exception is CAUGHT by the async machinery
// and delivered as a Future error. The caller gets a failed Future.
}
Future<void> bar() {
throw Exception('boom');
// The exception is thrown SYNCHRONOUSLY.
// If the caller doesn't have try-catch, it is an unhandled synchronous exception.
// It does NOT become a Future error.
}
void main() {
// This is SAFE:
foo().catchError((e) => print(e)); // Works — error is in the Future
// This CRASHES before catchError can help:
bar().catchError((e) => print(e)); // bar() throws synchronously, catchError is never reached
}
Key insight: async functions guarantee that all exceptions (even synchronous ones) are routed into the returned Future. Non-async functions that return Futures do not have this guarantee — synchronous code before the return can throw directly.
Timing difference:
void main() {
print('1');
foo().then((_) => print('foo done'));
print('2');
bar().then((_) => print('bar done'));
print('3');
}
// Output:
// 1
// 2
// 3
// foo done (or bar done — both are microtasks, order is deterministic but subtle)
// bar done
Both complete asynchronously because Future.value() and the async return both schedule on the microtask queue.
Q16: What's the output of this?
void main() {
String? name;
name ??= 'Alice';
name ??= 'Bob';
print(name);
}
What the interviewer is REALLY testing:
Do you understand that ??= is a no-op when the variable is already non-null?
Answer:
Output: Alice
String? name; // null
name ??= 'Alice'; // name is null → assign 'Alice'. Now name == 'Alice'.
name ??= 'Bob'; // name is 'Alice' (not null) → do nothing.
print(name); // Alice
Q17: What's the output?
void main() {
var x = [1, 2, 3];
var y = [1, 2, 3];
print(x == y);
print(identical(x, y));
const a = [1, 2, 3];
const b = [1, 2, 3];
print(a == b);
print(identical(a, b));
}
What the interviewer is REALLY testing:
Do you understand that List does NOT override == (so it uses identity), and that const collections are canonicalized (same content → same object)?
Answer:
Output:
false
false
true
true
Explanation:
-
x == y→falsebecauseListdoes NOT override==. DefaultObject.==checks identity.xandyare different objects. -
identical(x, y)→false— two separate list objects. -
a == b→truebecause both areconst. Dart canonicalizes const objects —aandbare the same object in memory.identical(a, b)is true, so==(identity check) is also true. -
identical(a, b)→true— const canonicalization.
This means: const does not make == smarter. It makes identical return true, and since default == checks identity, it becomes true as a side effect.
Q18: Explain the difference between is and as in Dart.
What the interviewer is REALLY testing:
Do you understand type testing vs type casting, and when each can fail?
Answer:
-
is— tests the type. Returnsbool. Never throws. -
as— casts the type. Returns the value as the target type. ThrowsTypeErrorif the cast is invalid.
Object value = 'hello';
// is — safe check
if (value is String) {
// Inside this block, Dart PROMOTES value to String automatically.
print(value.length); // No cast needed — smart cast / type promotion
}
// as — explicit cast (dangerous if wrong)
String s = value as String; // Works here
// int n = value as int; // RUNTIME ERROR: TypeError
// Combining them for safety:
String? safe = value is String ? value : null; // Safe alternative to as
Key insight — type promotion:
After an is check, Dart promotes the variable within that scope. You do not need as at all:
void process(Object obj) {
if (obj is! String) return; // early return
// From here on, obj is promoted to String
print(obj.length); // No cast needed
print(obj.toUpperCase()); // No cast needed
}
SECTION 2: ARCHITECTURE & DESIGN DILEMMAS
Q1: You're building a chat app — BLoC or Riverpod? Justify your choice with trade-offs.
What the interviewer is REALLY testing:
Can you think in trade-offs rather than absolutes? Do you have production experience with both? Can you match architecture to requirements?
Answer:
I would choose Riverpod for a chat app, but here is the full reasoning:
Why Riverpod fits chat better:
| Concern | Riverpod | BLoC |
|---|---|---|
| Multiple streams (messages, typing, presence, read receipts) | Natural — each is a separate provider, auto-disposed | Need multiple BLoCs or complex multi-stream BLoC |
| Reactive composition | Providers can watch other providers. Presence + messages → UI state |
Manual. Must inject BLoCs into BLoCs or use BlocListener chains |
| Disposal |
autoDispose per chat room — leave the screen, resources are freed |
Manual. Must close streams in dispose, or risk leaks |
| Testing | Override any provider in the test tree — no DI framework needed | Mockable, but need to set up stream expectations |
| Real-time WebSocket |
StreamProvider.autoDispose.family(chatRoomId) — one line |
Need StreamSubscription in BLoC, manual lifecycle |
When I would pick BLoC instead:
- The team already knows BLoC deeply and has established patterns.
- The app is enterprise-scale with strict event-driven audit logging (BLoC's event system gives you a traceable log of every state change).
- You need
bloc_concurrencyfor throttling/debouncing events (e.g., typing indicators). - The company has standardized on BLoC.
Riverpod chat architecture sketch:
// Per-room message stream
final chatMessagesProvider = StreamProvider.autoDispose.family<List<Message>, String>(
(ref, roomId) {
final socket = ref.watch(webSocketProvider);
return socket.messagesStream(roomId);
},
);
// Typing indicator
final typingUsersProvider = StreamProvider.autoDispose.family<List<String>, String>(
(ref, roomId) => ref.watch(webSocketProvider).typingStream(roomId),
);
// Send message action
final sendMessageProvider = Provider.autoDispose.family<void Function(String), String>(
(ref, roomId) {
final socket = ref.watch(webSocketProvider);
return (text) => socket.send(roomId, text);
},
);
Key point: There is no universally correct answer. The interviewer wants to see you reason about trade-offs, not declare a winner.
Q2: Your app needs to work fully offline and sync when online — design the architecture.
What the interviewer is REALLY testing:
Do you understand offline-first architecture, conflict resolution, and the complexity of syncing?
Answer:
Architecture: Local-first with sync queue.
┌─────────────────────────────────────────────┐
│ UI Layer │
│ (reads from local DB only) │
└──────────────┬──────────────────────────────┘
│ watches
┌──────────────▼──────────────────────────────┐
│ Local Database │
│ (Drift/Isar/Hive + SQLite) │
│ Source of truth for the UI. Always. │
└──────────────┬──────────────────────────────┘
│
┌──────────────▼──────────────────────────────┐
│ Sync Engine │
│ ┌─────────────────────────────────────┐ │
│ │ Operation Queue (pending changes) │ │
│ │ - create, update, delete ops │ │
│ │ - each op has a timestamp + UUID │ │
│ └──────────────┬──────────────────────┘ │
│ │ when online │
│ ┌──────────────▼──────────────────────┐ │
│ │ Conflict Resolver │ │
│ │ - Last-write-wins / merge / ask │ │
│ └──────────────┬──────────────────────┘ │
└─────────────────┼───────────────────────────┘
│
┌─────────────────▼───────────────────────────┐
│ Remote API / Firebase │
└─────────────────────────────────────────────┘
Key components:
// 1. Every model has sync metadata
class Todo {
final String id; // UUID generated on client
final String title;
final bool completed;
final DateTime updatedAt; // For conflict resolution
final SyncStatus syncStatus; // pending, synced, conflict
}
enum SyncStatus { synced, pendingUpload, pendingDelete, conflict }
// 2. Repository always writes locally first
class TodoRepository {
final LocalDatabase _db;
final SyncQueue _syncQueue;
Future<void> addTodo(Todo todo) async {
await _db.insertTodo(todo.copyWith(syncStatus: SyncStatus.pendingUpload));
_syncQueue.enqueue(SyncOperation.create(todo));
}
}
// 3. Sync engine runs when connectivity changes
class SyncEngine {
final Connectivity _connectivity;
void init() {
_connectivity.onConnectivityChanged.listen((status) {
if (status != ConnectivityResult.none) {
_processQueue();
}
});
}
Future<void> _processQueue() async {
final pending = await _db.getPendingOperations();
for (final op in pending) {
try {
await _api.sync(op);
await _db.markSynced(op.id);
} on ConflictException catch (e) {
await _resolveConflict(op, e.serverVersion);
}
}
// Pull server changes
final serverChanges = await _api.getChangesSince(_lastSyncTimestamp);
await _db.mergeServerChanges(serverChanges);
}
}
Conflict resolution strategies:
-
Last-write-wins — compare
updatedAttimestamps, latest wins. Simple but can lose data. - Field-level merge — merge non-conflicting field changes. Complex but preserves more data.
- User resolves — show both versions and let the user pick. Best UX but requires UI.
- CRDTs — Conflict-free Replicated Data Types. No conflicts by design, but complex to implement.
Database choice: Drift (SQLite wrapper) for complex queries, or Isar for simpler NoSQL with built-in sync-friendly features.
Q3: How would you handle authentication token refresh transparently across the entire app?
What the interviewer is REALLY testing:
Can you implement an interceptor pattern? Do you understand race conditions when multiple requests hit 401 simultaneously?
Answer:
Use an HTTP interceptor (Dio interceptor) with a token refresh queue.
class AuthInterceptor extends Interceptor {
final Dio _dio;
final AuthStorage _storage;
// Mutex to prevent multiple simultaneous refresh calls
Completer<String>? _refreshCompleter;
AuthInterceptor(this._dio, this._storage);
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) async {
final token = await _storage.getAccessToken();
if (token != null) {
options.headers['Authorization'] = 'Bearer $token';
}
handler.next(options);
}
@override
void onError(DioException err, ErrorInterceptorHandler handler) async {
if (err.response?.statusCode != 401) {
return handler.next(err);
}
// 401 — token expired. Refresh it.
try {
final newToken = await _refreshToken();
// Retry the original request with the new token.
final options = err.requestOptions;
options.headers['Authorization'] = 'Bearer $newToken';
final response = await _dio.fetch(options);
handler.resolve(response);
} catch (e) {
// Refresh failed — force logout.
await _storage.clear();
handler.reject(err); // Or navigate to login
}
}
Future<String> _refreshToken() async {
// If a refresh is already in progress, wait for it.
if (_refreshCompleter != null) {
return _refreshCompleter!.future;
}
_refreshCompleter = Completer<String>();
try {
final refreshToken = await _storage.getRefreshToken();
// Use a SEPARATE Dio instance to avoid interceptor loop!
final freshDio = Dio();
final response = await freshDio.post('/auth/refresh', data: {
'refresh_token': refreshToken,
});
final newAccessToken = response.data['access_token'];
final newRefreshToken = response.data['refresh_token'];
await _storage.saveTokens(newAccessToken, newRefreshToken);
_refreshCompleter!.complete(newAccessToken);
return newAccessToken;
} catch (e) {
_refreshCompleter!.completeError(e);
rethrow;
} finally {
_refreshCompleter = null;
}
}
}
Critical details:
-
Mutex pattern with
Completer— If 5 requests all get 401, only the first triggers a refresh. The other 4 await the sameCompleter.future. - Separate Dio instance for the refresh call — otherwise the interceptor intercepts the refresh request itself, causing an infinite loop.
- Retry the original request after refresh — the user never knows the token expired.
- Force logout if refresh fails — the refresh token itself is expired.
Q4: You have a feature that 3 different screens need — where does the shared state live?
What the interviewer is REALLY testing:
Do you understand state scoping, and can you reason about where state belongs in the widget tree?
Answer:
It depends on the relationship between the screens. There are three patterns:
Pattern 1: Common ancestor in widget tree (screens are children of the same navigator)
Place the state above all three screens in the widget tree.
// With Riverpod — state is naturally global/scoped to ProviderScope
final sharedCartProvider = StateNotifierProvider<CartNotifier, CartState>(
(ref) => CartNotifier(),
);
// All three screens just: ref.watch(sharedCartProvider)
// With BLoC — use BlocProvider above the Navigator
MaterialApp(
home: BlocProvider(
create: (_) => CartBloc(),
child: Navigator(...), // All screens below can access CartBloc
),
);
Pattern 2: Screens are in different navigation stacks (e.g., bottom nav tabs)
State must live at the app level, above all navigators.
// Riverpod: Already global by nature. No change needed.
// BLoC: Provide above MaterialApp or above the bottom nav scaffold
MultiBlocProvider(
providers: [
BlocProvider(create: (_) => SharedFeatureBloc()),
],
child: MaterialApp(home: BottomNavScaffold()),
);
Pattern 3: Screens are in different flows but share data (e.g., user profile)
Use a service/repository layer that the state management reads from.
// The repository is the single source of truth
class UserRepository {
final _userController = BehaviorSubject<User>();
Stream<User> get userStream => _userController.stream;
void updateUser(User user) {
_api.updateUser(user);
_userController.add(user); // All listeners get the update
}
}
// Each screen's BLoC/Provider listens to the repository
final userProvider = StreamProvider<User>(
(ref) => ref.watch(userRepositoryProvider).userStream,
);
The decision framework:
- If state is UI-only (tab selection, form input): keep it as local as possible.
- If state represents domain data (cart, user, orders): put it in a repository and expose via state management.
- If state must survive screen disposal: it must be above the screen in the tree or in a service layer.
Q5: How do you handle deep linking that requires multiple screens in the back stack?
What the interviewer is REALLY testing:
Do you understand declarative routing vs imperative routing? Have you dealt with the complexity of restoring a navigation stack from a URL?
Answer:
The problem: User clicks a link like myapp://orders/123/item/456. The app should show: Home → Orders → Order #123 → Item #456 — with the full back stack so the user can press back through each screen.
Solution with GoRouter (recommended):
final router = GoRouter(
routes: [
GoRoute(
path: '/',
builder: (_, __) => const HomeScreen(),
routes: [
GoRoute(
path: 'orders',
builder: (_, __) => const OrdersScreen(),
routes: [
GoRoute(
path: ':orderId',
builder: (_, state) => OrderDetailScreen(
orderId: state.pathParameters['orderId']!,
),
routes: [
GoRoute(
path: 'item/:itemId',
builder: (_, state) => ItemDetailScreen(
orderId: state.pathParameters['orderId']!,
itemId: state.pathParameters['itemId']!,
),
),
],
),
],
),
],
),
],
);
GoRouter automatically builds the back stack based on the route nesting. Navigating to /orders/123/item/456 creates the stack: Home → Orders → Order 123 → Item 456.
For more complex scenarios (Navigator 2.0 / custom):
class AppRouterDelegate extends RouterDelegate<AppRoutePath> {
@override
Widget build(BuildContext context) {
return Navigator(
pages: _buildPageStack(currentPath),
onPopPage: _handlePop,
);
}
List<Page> _buildPageStack(AppRoutePath path) {
final pages = <Page>[const MaterialPage(child: HomeScreen())];
if (path is OrdersPath || path is OrderDetailPath || path is ItemDetailPath) {
pages.add(const MaterialPage(child: OrdersScreen()));
}
if (path is OrderDetailPath || path is ItemDetailPath) {
pages.add(MaterialPage(child: OrderDetailScreen(orderId: path.orderId)));
}
if (path is ItemDetailPath) {
pages.add(MaterialPage(child: ItemDetailScreen(
orderId: path.orderId, itemId: path.itemId,
)));
}
return pages;
}
}
Platform setup required:
-
Android: Intent filters in
AndroidManifest.xml+ App Links verification. - iOS: Associated Domains + Universal Links.
-
Web: URL strategy —
usePathUrlStrategy()inmain().
Q6: Your team has 10 developers — how do you structure the Flutter project for parallel work?
What the interviewer is REALLY testing:
Do you have experience scaling Flutter teams? Can you think about code organization, merge conflicts, and build times?
Answer:
Use a modular monorepo with feature packages.
my_app/
├── apps/
│ └── my_app/ # Thin shell app — just wires features together
│ ├── lib/
│ │ ├── main.dart
│ │ ├── app.dart
│ │ └── router.dart
│ └── pubspec.yaml # depends on all feature packages
├── packages/
│ ├── core/
│ │ ├── core_ui/ # Design system, shared widgets, theme
│ │ ├── core_network/ # Dio setup, interceptors, API client
│ │ ├── core_data/ # Base repository, local DB setup
│ │ └── core_domain/ # Shared models, interfaces
│ └── features/
│ ├── feature_auth/ # Login, signup, forgot password
│ ├── feature_chat/ # Chat feature — owned by Team A
│ ├── feature_profile/ # Profile — owned by Team B
│ ├── feature_orders/ # Orders — owned by Team C
│ └── feature_payments/ # Payments — owned by Team D
└── melos.yaml # Monorepo management
Key principles:
-
Each feature is a separate Dart package with its own
pubspec.yaml, tests, and clear API surface. -
Features depend on core packages, never on each other. If chat needs user data, it depends on
core_domainfor theUsermodel, not onfeature_profile. - Use Melos for monorepo management — run tests, analyze, and publish across all packages.
- Code ownership: Each team owns one or more feature packages. PRs to their packages are reviewed by their team. PRs to core packages require broader review.
- Dependency injection at the app level: The shell app wires features together via routing and DI, not direct imports.
# melos.yaml
name: my_app_workspace
packages:
- apps/**
- packages/core/**
- packages/features/**
scripts:
analyze:
run: melos exec -- dart analyze .
test:
run: melos exec -- flutter test
test:selective:
run: melos exec --since=main -- flutter test # Only test changed packages
Benefits:
- 10 developers rarely touch the same files → fewer merge conflicts.
- Each package compiles and tests independently → faster CI.
- Clear boundaries force clean APIs between features.
- A developer can work on
feature_chatwithout even knowingfeature_paymentsexists.
Q7: How do you handle version-specific API responses (v1 vs v2) without breaking the app?
What the interviewer is REALLY testing:
Can you design for API evolution? Do you understand backward compatibility, graceful degradation, and the adapter pattern?
Answer:
Use the adapter/strategy pattern with version negotiation.
// 1. Define a stable internal model (domain model)
class User {
final String id;
final String displayName;
final String email;
User({required this.id, required this.displayName, required this.email});
}
// 2. Version-specific response DTOs and mappers
class UserV1Dto {
final String id;
final String firstName;
final String lastName;
final String email;
User toDomain() => User(
id: id,
displayName: '$firstName $lastName', // v1 has separate name fields
email: email,
);
factory UserV1Dto.fromJson(Map<String, dynamic> json) => UserV1Dto(
id: json['id'], firstName: json['first_name'],
lastName: json['last_name'], email: json['email'],
);
}
class UserV2Dto {
final String id;
final String displayName; // v2 has combined name
final String email;
final String? avatarUrl; // v2 adds avatar
User toDomain() => User(
id: id,
displayName: displayName,
email: email,
);
factory UserV2Dto.fromJson(Map<String, dynamic> json) => UserV2Dto(
id: json['id'], displayName: json['display_name'],
email: json['email'], avatarUrl: json['avatar_url'],
);
}
// 3. API version strategy
abstract class UserApi {
Future<User> getUser(String id);
}
class UserApiV1 implements UserApi {
@override
Future<User> getUser(String id) async {
final response = await dio.get('/v1/users/$id');
return UserV1Dto.fromJson(response.data).toDomain();
}
}
class UserApiV2 implements UserApi {
@override
Future<User> getUser(String id) async {
final response = await dio.get('/v2/users/$id');
return UserV2Dto.fromJson(response.data).toDomain();
}
}
// 4. Version negotiation at startup
class ApiVersionResolver {
Future<UserApi> resolveUserApi() async {
final config = await remoteConfig.fetch();
final version = config.getString('user_api_version');
switch (version) {
case 'v2': return UserApiV2();
default: return UserApiV1(); // Fallback to v1
}
}
}
The key principle: Your domain models and UI never know which API version is in use. The adapter layer absorbs all version differences.
Q8: When would you choose Method Channel over FFI? Give a real-world scenario.
What the interviewer is REALLY testing:
Do you understand the technical differences and when platform-specific APIs are unavoidable?
Answer:
| Aspect | Method Channel | dart:ffi |
|---|---|---|
| What it calls | Platform APIs (Java/Kotlin, ObjC/Swift) | C/C++ native libraries |
| Async | Yes (message passing across thread boundary) | No (synchronous, same thread) |
| Platform UI access | Yes (can access Activities, UIViewControllers) | No |
| Performance | Overhead per call (serialization) | Near-zero overhead |
| Use case | Platform services, sensors, OS features | Math, crypto, image processing |
Choose Method Channel — real-world scenario: Biometric authentication.
You need to access Face ID on iOS (LocalAuthentication framework) and Fingerprint on Android (BiometricPrompt API). These are platform UI APIs — they show system dialogs, interact with the OS security enclave, and require platform-specific lifecycle handling.
// Dart side
static const platform = MethodChannel('com.app/biometric');
Future<bool> authenticate() async {
final result = await platform.invokeMethod<bool>('authenticate', {
'reason': 'Verify your identity',
});
return result ?? false;
}
// Kotlin side (Android)
class BiometricPlugin : MethodCallHandler {
override fun onMethodCall(call: MethodCall, result: Result) {
if (call.method == "authenticate") {
val prompt = BiometricPrompt(activity, executor, callback)
prompt.authenticate(promptInfo) // Shows system UI
}
}
}
FFI cannot do this — there is no C library for Face ID or Android BiometricPrompt.
Choose FFI — real-world scenario: Image processing with OpenCV.
You have a C++ library for real-time image filters. Each frame needs processing in under 16ms. Method Channel's serialization overhead would kill performance.
// Direct FFI call — no serialization, no async overhead
final dylib = DynamicLibrary.open('libimage_processor.so');
final processFrame = dylib.lookupFunction<
Void Function(Pointer<Uint8>, Int32, Int32),
void Function(Pointer<Uint8>, int, int)
>('process_frame');
processFrame(framePointer, width, height); // Microseconds, not milliseconds
Q9: How would you design a plugin that works on iOS, Android, Web, and Desktop?
What the interviewer is REALLY testing:
Do you understand the federated plugin architecture? Have you built or studied multi-platform plugins?
Answer:
Use Flutter's federated plugin architecture. One plugin, multiple platform packages.
my_plugin/
├── my_plugin/ # App-facing package (what users import)
│ ├── lib/
│ │ └── my_plugin.dart # Delegates to platform interface
│ └── pubspec.yaml
├── my_plugin_platform_interface/ # Abstract contract
│ ├── lib/
│ │ ├── my_plugin_platform_interface.dart
│ │ └── method_channel_my_plugin.dart # Default MethodChannel implementation
│ └── pubspec.yaml
├── my_plugin_android/ # Android implementation
│ ├── android/
│ ├── lib/my_plugin_android.dart
│ └── pubspec.yaml
├── my_plugin_ios/ # iOS implementation
│ ├── ios/
│ ├── lib/my_plugin_ios.dart
│ └── pubspec.yaml
├── my_plugin_web/ # Web implementation
│ ├── lib/my_plugin_web.dart # Uses dart:js_interop
│ └── pubspec.yaml
└── my_plugin_desktop/ # Desktop (Windows/macOS/Linux)
├── lib/my_plugin_desktop.dart # Uses FFI
└── pubspec.yaml
// Platform interface — the contract
abstract class MyPluginPlatform extends PlatformInterface {
static MyPluginPlatform _instance = MethodChannelMyPlugin();
static MyPluginPlatform get instance => _instance;
static set instance(MyPluginPlatform instance) {
PlatformInterface.verify(instance, _token);
_instance = instance;
}
Future<String> getPlatformInfo();
}
// App-facing API — clean and simple
class MyPlugin {
Future<String> getPlatformInfo() {
return MyPluginPlatform.instance.getPlatformInfo();
}
}
// Android implementation — registers itself
class MyPluginAndroid extends MyPluginPlatform {
static void registerWith() {
MyPluginPlatform.instance = MyPluginAndroid();
}
@override
Future<String> getPlatformInfo() async {
// MethodChannel call to Kotlin code
return await methodChannel.invokeMethod('getPlatformInfo');
}
}
// Web implementation — uses JS interop
class MyPluginWeb extends MyPluginPlatform {
static void registerWith(Registrar registrar) {
MyPluginPlatform.instance = MyPluginWeb();
}
@override
Future<String> getPlatformInfo() async {
return js_util.callMethod(window, 'getPlatformInfo', []);
}
}
Why federated?
- Teams can maintain platform implementations independently.
- Adding a new platform does not touch existing code.
- Users only pull in the platforms they target — no unused native code.
Q10: Your app is 80MB — the PM wants it under 30MB. What do you cut and how?
What the interviewer is REALLY testing:
Do you understand what contributes to Flutter app size? Have you actually optimized an APK/IPA?
Answer:
Step 1: Analyze what is taking space.
# Build with size analysis
flutter build apk --analyze-size
flutter build appbundle --analyze-size
# Opens a treemap in the browser showing exactly what takes space
Step 2: The biggest offenders and how to fix each.
| Offender | Typical Size | Fix |
|---|---|---|
| Native libraries (Flutter engine) | ~5-8MB | Cannot reduce. This is the floor. |
| Assets (images, fonts, animations) | 10-40MB | Biggest opportunity. See below. |
| Dart code (compiled) | 2-5MB | Tree shaking, deferred loading |
| Unused native plugins | 1-5MB each | Remove unused plugins from pubspec |
| Font files | 1-5MB | Use only needed font weights |
| Debug symbols | 10-20MB |
--split-debug-info strips them |
Concrete actions:
# 1. Split debug info (saves 10-20MB immediately)
flutter build apk --split-debug-info=./debug-symbols --obfuscate
# 2. Use App Bundles instead of APK (Google Play delivers only needed ABIs)
flutter build appbundle # Instead of flutter build apk
# An APK contains arm64 + arm32 + x86. App bundle delivers only the needed one.
# Saves ~30-40% of native library size.
# 3. Use --split-per-abi for APKs
flutter build apk --split-per-abi
# Produces separate APKs: app-arm64-v8a.apk (~15MB smaller), etc.
// 4. Deferred imports — load features on demand
import 'package:app/features/reports/reports.dart' deferred as reports;
Future<void> showReports() async {
await reports.loadLibrary();
navigator.push(reports.ReportsScreen());
}
5. Asset optimization:
- Compress PNGs with
pngquant(60-80% reduction). - Use WebP instead of PNG/JPEG (30-50% smaller).
- Use Lottie JSON instead of GIF animations (90% smaller).
- Use SVGs via
flutter_svginstead of multi-resolution PNGs. - Download large assets on demand instead of bundling.
- Remove unused assets — audit
pubspec.yamlassets section.
6. Font optimization:
# Instead of including all weights:
fonts:
- family: Roboto
fonts:
- asset: fonts/Roboto-Regular.ttf # Only weights you actually use
- asset: fonts/Roboto-Bold.ttf
weight: 700
# Remove Roboto-Thin, Light, Medium, Black, etc.
7. Audit dependencies:
# Check which packages pull in native code
flutter pub deps
# Each native plugin adds native libraries. Remove unused ones.
# Common bloat: unused Firebase packages, camera plugin when not needed, etc.
Realistic target: A well-optimized Flutter app with moderate features should be 15-25MB as an app bundle.
SECTION 3: DEBUGGING & "WHAT WENT WRONG?" SCENARIOS
Q1: Your app works in debug but crashes in release mode — what could cause this?
What the interviewer is REALLY testing:
Do you understand the differences between debug and release compilation? Have you dealt with release-only bugs?
Answer:
Top causes, from most common to least:
1. ProGuard/R8 code shrinking (Android)
Release mode enables R8 (code shrinking/obfuscation). It removes "unused" code — but sometimes removes code that is used via reflection (JSON serialization, method channels).
// android/app/build.gradle — check these
buildTypes {
release {
shrinkResources true
minifyEnabled true
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}
// Fix: Add keep rules in proguard-rules.pro
-keep class com.yourpackage.** { *; }
-keep class io.flutter.** { *; }
2. Missing Internet permission (Android)
Debug mode implicitly allows internet. Release mode on some devices requires explicit permission.
<!-- android/app/src/main/AndroidManifest.xml -->
<uses-permission android:name="android.permission.INTERNET" />
3. Tree shaking removes code used via reflection
dart:mirrors is not available in release. If any dependency uses it, it breaks.
4. Timing-dependent race conditions
Debug mode is slower (JIT, assertions, debug checks). Release mode is AOT-compiled and faster. Race conditions that were hidden by debug slowness now manifest.
// This might "work" in debug but fail in release:
late final data = fetchData(); // Timing may differ
5. Assertion removal
assert() statements run in debug, are removed in release.
assert(list.isNotEmpty); // Runs in debug, removed in release
doSomething(list.first); // Crashes in release if list is empty
6. iOS App Transport Security
Debug builds may bypass ATS. Release builds enforce HTTPS. HTTP URLs fail silently.
7. dart:developer usage
Timeline, debugger(), and other dart:developer features behave differently in release.
Debugging strategy:
# Build release with debug symbols for crash logs
flutter build apk --release --split-debug-info=./symbols --obfuscate
# Use symbols to decode stack traces:
flutter symbolize -i crash_log.txt -d ./symbols
Q2: setState is called but UI doesn't update — list all possible reasons.
What the interviewer is REALLY testing:
Do you understand the widget lifecycle, state management, and how Flutter's rendering pipeline works?
Answer:
1. Mutating an object without changing its reference
List<String> items = ['a', 'b'];
void addItem() {
setState(() {
items.add('c'); // Same list reference — some widgets won't detect the change
});
}
// Fix: Create a new list
setState(() {
items = [...items, 'c'];
});
2. The widget is not actually rebuilt
The State's build method runs, but the part that reads the changed value is in a child widget that has its own state and is not being rebuilt.
3. setState called on a widget that is not mounted
// No visible error in some cases, setState is just ignored if !mounted
if (mounted) {
setState(() { ... });
}
4. The value changed but the widget tree compares identical
Flutter's reconciliation may skip a subtree if the widget's operator == returns true (e.g., const widgets, or widgets with @immutable that look the same).
5. Using the wrong BuildContext
If you have nested navigators or overlays, the context might not be in the tree you think it is.
6. Animation/async gap — the value updates after build completes
void loadData() {
setState(() { isLoading = true; });
fetchData().then((data) {
// If you forgot setState here, UI shows loading forever
setState(() {
this.data = data;
isLoading = false;
});
});
}
7. Keys causing widget recreation instead of update
Using UniqueKey() forces a fresh State, losing your changes. Using no key when you should causes Flutter to reuse the wrong State.
8. State is in the wrong place
The value is stored in a parent widget's state but the child calls setState on itself — which rebuilds only the child, not the parent that holds the value.
9. Overridden shouldRebuild or updateShouldNotify
Custom InheritedWidget with updateShouldNotify returning false. Custom delegate with shouldRebuild returning false.
Q3: Hot reload doesn't work / shows old UI — why?
What the interviewer is REALLY testing:
Do you know the limitations of hot reload? This is a practical day-to-day issue.
Answer:
Hot reload CANNOT handle these changes — you need hot restart or full restart:
-
Changes to
main()— app entry point is not re-executed on hot reload. - Changes to global variables / static fields — they keep their old values.
-
Changes to
initState()— already called, will not be called again. - Enum changes — adding/removing/reordering enum values.
- Generic type changes — changing type parameters.
-
Changes from
StatelessWidgettoStatefulWidget(or vice versa). - Changes to native code (Kotlin/Swift) — requires full rebuild.
-
Changes to
constconstructors — const values are inlined at compile time. - Adding/removing/renaming classes — sometimes fails gracefully, sometimes not.
Other causes of stale UI:
- IDE not saving the file — hot reload triggers on save.
- Syntax error in the file — hot reload fails silently (check the console).
- Plugin code changed — only Dart code hot reloads.
- Build flavor mismatch — running the wrong configuration.
The fix:
-
rin terminal = hot reload. -
Rin terminal = hot restart (resets state, re-runsmain()). - Stop and rebuild = nuclear option for native code changes.
Q4: "A RenderFlex overflowed by X pixels" — what causes this and 5 ways to fix it?
What the interviewer is REALLY testing:
This is the most common Flutter layout error. Can you explain WHY it happens at the rendering level, not just how to suppress it?
Answer:
Why it happens:
A Row or Column (which use RenderFlex) has children whose total size along the main axis exceeds the available space. The RenderFlex cannot lay out its children within the given constraints.
Common trigger: Long text, too many items, or a non-scrollable container with oversized content.
5 ways to fix:
1. Wrap overflowing children in Flexible or Expanded
// BROKEN:
Row(children: [Text('Very long text that overflows'), Icon(Icons.star)])
// FIXED:
Row(children: [Expanded(child: Text('Very long text...', overflow: TextOverflow.ellipsis)), Icon(Icons.star)])
2. Make the container scrollable
// BROKEN:
Column(children: List.generate(100, (i) => ListTile(title: Text('Item $i'))))
// FIXED:
SingleChildScrollView(
child: Column(children: List.generate(100, (i) => ListTile(title: Text('Item $i')))),
)
// Or better: use ListView instead of Column
3. Use overflow / TextOverflow to truncate
Text('Long text', overflow: TextOverflow.ellipsis, maxLines: 1)
4. Wrap children using Wrap instead of Row
// BROKEN: Row with too many chips
Row(children: chips) // Overflows if too many
// FIXED: Wrap flows to next line
Wrap(spacing: 8, runSpacing: 4, children: chips)
5. Use ConstrainedBox, SizedBox, or FractionallySizedBox to limit size
ConstrainedBox(
constraints: BoxConstraints(maxWidth: 200),
child: Text('This text will wrap within 200px instead of overflowing'),
)
Q5: "setState() called after dispose()" — why does this happen and how to fix properly?
What the interviewer is REALLY testing:
Do you understand async gaps and widget lifecycle? This is one of the most common real-world bugs.
Answer:
Why it happens: An async operation (Future, Timer, Stream) completes after the widget has been removed from the tree. The callback tries to call setState on a disposed State.
class MyWidget extends StatefulWidget { ... }
class _MyWidgetState extends State<MyWidget> {
String data = '';
@override
void initState() {
super.initState();
fetchData().then((result) {
setState(() { data = result; }); // CRASH if widget was disposed during fetch
});
}
}
Fix 1: Check mounted (simplest)
fetchData().then((result) {
if (!mounted) return; // Widget is gone, do nothing
setState(() { data = result; });
});
Fix 2: Cancel the operation in dispose (proper)
class _MyWidgetState extends State<MyWidget> {
StreamSubscription? _sub;
Timer? _timer;
@override
void initState() {
super.initState();
_sub = myStream.listen((data) {
setState(() { this.data = data; });
});
_timer = Timer.periodic(Duration(seconds: 5), (_) {
setState(() { /* update */ });
});
}
@override
void dispose() {
_sub?.cancel(); // Cancel stream subscription
_timer?.cancel(); // Cancel timer
super.dispose();
}
}
Fix 3: Use CancelableOperation from package:async
late CancelableOperation<String> _operation;
@override
void initState() {
super.initState();
_operation = CancelableOperation.fromFuture(fetchData());
_operation.value.then((result) {
setState(() { data = result; });
});
}
@override
void dispose() {
_operation.cancel();
super.dispose();
}
The root cause is always the same: you have a reference from an async closure back to a State that no longer exists. The fix is always: either guard with mounted or cancel the async work.
Q6: The app is slow only on first launch — what causes this?
What the interviewer is REALLY testing:
Do you understand Dart compilation, shader compilation, and cold start optimization?
Answer:
1. Shader compilation jank (the #1 cause)
Flutter compiles GPU shaders on first use. The first time a visual effect is rendered, there is a visible jank/stutter.
# Fix: Pre-warm shaders
# Step 1: Capture shaders during a test run
flutter drive --profile --cache-sksl --write-sksl-on-exit flutter_01.sksl.json
# Step 2: Bundle them with the build
flutter build apk --bundle-sksl-warmup flutter_01.sksl.json
With Impeller (default on iOS, coming to Android), shader compilation jank is largely eliminated.
2. Large synchronous initialization in main()
// SLOW — blocks the first frame
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp(); // Network call
await Hive.initFlutter(); // Disk I/O
await loadAllConfig(); // More I/O
await preloadImages(); // Image decoding
runApp(MyApp());
}
// FAST — show UI immediately, load in background
void main() async {
WidgetsFlutterBinding.ensureInitialized();
runApp(MyApp()); // Show splash immediately
}
// Inside MyApp, use FutureBuilder or splash screen to init in parallel
3. Dart VM startup and AOT compilation
The first launch loads the AOT-compiled snapshot into memory. Subsequent launches benefit from OS-level caching.
4. Large asset loading
If initState loads large JSON, databases, or images synchronously, the first frame is delayed.
5. Plugin initialization
Firebase, Google Maps, and other plugins with native initialization add to cold start time.
Measurement:
# Measure startup time
flutter run --trace-startup
# Generates a timeline in build/start_up_info.json
# Shows time to: engine init, framework init, first frame
Q7: Images are blurry on some devices — why?
What the interviewer is REALLY testing:
Do you understand device pixel ratio, asset resolution, and how Flutter selects image assets?
Answer:
Cause: The image resolution does not match the device pixel ratio (DPR). A device with DPR 3.0 displaying a 1x image scales it up → blurry.
How Flutter selects images:
assets/
images/
logo.png ← 1x (100x100 px)
2.0x/logo.png ← 2x (200x200 px)
3.0x/logo.png ← 3x (300x300 px)
4.0x/logo.png ← 4x (400x400 px)
Flutter picks the variant closest to (but not less than) the device's pixel ratio. If the device has DPR 3.0 and you only have 1x, it stretches 100px → 300px → blurry.
Fixes:
- Provide all density variants:
flutter:
assets:
- assets/images/
- assets/images/2.0x/
- assets/images/3.0x/
- Use SVG for icons and simple graphics:
SvgPicture.asset('assets/logo.svg') // Infinitely scalable, never blurry
- For network images, request the right resolution:
final dpr = MediaQuery.of(context).devicePixelRatio;
final width = 100 * dpr; // Request appropriate size from CDN
Image.network('https://cdn.example.com/photo.jpg?w=${width.toInt()}')
-
Set
filterQualityfor better scaling:
Image.asset('assets/photo.png', filterQuality: FilterQuality.high)
Q8: "Looking up a deactivated widget's ancestor is unsafe" — what causes this?
What the interviewer is REALLY testing:
Do you understand widget lifecycle states (active, deactivated, disposed) and when context becomes invalid?
Answer:
Cause: You are using a BuildContext after its widget has been removed from the tree but before it is fully disposed. This typically happens in async callbacks after navigation.
// BROKEN:
onPressed: () async {
await someAsyncWork();
// By now, the user may have navigated away. This widget is deactivated.
Navigator.of(context).push(...); // CRASH — context is deactivated
ScaffoldMessenger.of(context).showSnackBar(...); // CRASH
}
Why "deactivated" specifically: When a widget is removed from the tree (e.g., navigated away), Flutter deactivates it before disposing. During deactivation, the widget's ancestors are being unlinked. Looking up an ancestor at this point could return stale or inconsistent data.
Fixes:
// Fix 1: Check mounted
onPressed: () async {
await someAsyncWork();
if (!context.mounted) return; // Dart 3.7+ — context.mounted
Navigator.of(context).push(...);
}
// Fix 2: Capture the navigator before the async gap
onPressed: () async {
final navigator = Navigator.of(context); // Capture BEFORE await
final messenger = ScaffoldMessenger.of(context);
await someAsyncWork();
navigator.push(...); // Use captured reference
messenger.showSnackBar(...);
}
// Fix 3: Use a GlobalKey (for edge cases)
final scaffoldKey = GlobalKey<ScaffoldMessengerState>();
// ...
scaffoldKey.currentState?.showSnackBar(...);
Q9: Your HTTP request works on Android but fails on iOS — what went wrong?
What the interviewer is REALLY testing:
Do you know platform-specific networking restrictions? This is a classic production gotcha.
Answer:
Cause #1 (most common): App Transport Security (ATS)
iOS blocks all HTTP (non-HTTPS) requests by default. Android does not (below API 28).
<!-- ios/Runner/Info.plist — allow HTTP (not recommended for production) -->
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
<!-- Better: allow specific domains -->
<key>NSAppTransportSecurity</key>
<dict>
<key>NSExceptionDomains</key>
<dict>
<key>example.com</key>
<dict>
<key>NSTemporaryExceptionAllowsInsecureHTTPLoads</key>
<true/>
</dict>
</dict>
</dict>
Cause #2: Self-signed SSL certificates
iOS is stricter about certificate validation. Self-signed certs that work on Android fail on iOS.
// Workaround for development (NEVER in production):
HttpOverrides.global = MyHttpOverrides();
class MyHttpOverrides extends HttpOverrides {
@override
HttpClient createHttpClient(SecurityContext? context) {
return super.createHttpClient(context)
..badCertificateCallback = (cert, host, port) => true;
}
}
Cause #3: Missing capabilities in iOS
Some network features require specific entitlements in Xcode (e.g., Multicast, VPN).
Cause #4: TLS version mismatch
Your server uses TLS 1.0/1.1. iOS 15+ requires TLS 1.2 minimum.
Cause #5: IPv6 requirement
Apple requires IPv6 compatibility for App Store. If your server is IPv4-only, it may fail on certain iOS networks.
Q10: Keyboard opens and causes overflow — how to handle?
What the interviewer is REALLY testing:
Do you understand how keyboard insets work and how Scaffold handles them?
Answer:
Why it happens: When the keyboard appears, it pushes the content up by default (resizeToAvoidBottomInset: true). If the layout is not flexible, content overflows.
Fix 1: Wrap in SingleChildScrollView
Scaffold(
body: SingleChildScrollView(
child: Column(
children: [
// ...form fields
],
),
),
)
Fix 2: Disable resize (content goes behind keyboard)
Scaffold(
resizeToAvoidBottomInset: false, // Keyboard overlaps content
body: ...,
)
Fix 3: Use MediaQuery.of(context).viewInsets.bottom for manual padding
Padding(
padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
child: ...,
)
Fix 4: Scroll to the focused field automatically
// Using Scrollable.ensureVisible:
FocusNode _focusNode = FocusNode();
TextField(
focusNode: _focusNode,
onTap: () {
Future.delayed(Duration(milliseconds: 300), () {
Scrollable.ensureVisible(
context,
duration: Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
});
},
)
Fix 5: Use ListView instead of Column — it is scrollable by default.
Q11: "Vertical viewport was given unbounded height" — explain why and all ways to fix.
What the interviewer is REALLY testing:
Do you understand Flutter's constraint system and why scrollables need bounded constraints?
Answer:
Why it happens: A ListView (or any scrollable) is placed inside a parent that gives it unbounded height (infinite). The ListView needs to know its viewport height to calculate how many items to render, but it receives double.infinity.
The classic trigger:
// CRASH: Column gives unbounded height to children in the main axis
Column(
children: [
ListView( // Tries to be infinitely tall inside an already-infinite column
children: [...],
),
],
)
Why Column causes this: A Column lays out non-flex children first with unbounded main-axis constraints. ListView receives maxHeight: infinity, panics because it cannot create a viewport without a finite height.
All fixes:
1. Wrap ListView in Expanded (most common)
Column(
children: [
Text('Header'),
Expanded(
child: ListView(children: [...]),
),
],
)
2. Give ListView a fixed height with SizedBox
Column(
children: [
SizedBox(
height: 300,
child: ListView(children: [...]),
),
],
)
3. Use shrinkWrap: true (use sparingly — kills virtualization)
Column(
children: [
ListView(
shrinkWrap: true, // ListView sizes itself to its content
physics: NeverScrollableScrollPhysics(), // Disable inner scrolling
children: [...],
),
],
)
// WARNING: shrinkWrap builds ALL items. No lazy loading. Bad for long lists.
4. Use Flexible instead of Expanded
Column(
children: [
Flexible(
child: ListView(children: [...]),
),
],
)
// Flexible lets ListView take remaining space but can also be smaller.
5. Replace Column + ListView with a single CustomScrollView
CustomScrollView(
slivers: [
SliverToBoxAdapter(child: Text('Header')),
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) => ListTile(title: Text('Item $index')),
childCount: 100,
),
),
],
)
// Best solution: single scrollable, fully virtualized.
Q12: Firebase notifications work in foreground but not background — why?
What the interviewer is REALLY testing:
Do you understand the different execution contexts for push notifications on iOS and Android?
Answer:
Android causes:
- Background handler not registered as a top-level function.
// BROKEN — inside a class or closure
class App {
void handler(RemoteMessage msg) {} // Not top-level
}
// FIXED — must be a top-level function (not a method, not a closure)
@pragma('vm:entry-point')
Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
await Firebase.initializeApp();
print('Background message: ${message.messageId}');
}
void main() {
FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler);
runApp(MyApp());
}
Missing
Firebase.initializeApp()in background handler. The background handler runs in a separate isolate — Firebase is not initialized there.Battery optimization killing the app. Some OEMs (Xiaomi, Huawei, Samsung) aggressively kill background processes.
iOS causes:
-
Missing background modes capability.
- In Xcode: Signing & Capabilities → Background Modes → check "Remote notifications".
Missing
content-available: 1in the push payload. iOS needs this flag for silent/background notifications.APNs configuration. iOS uses APNs, not FCM directly. Missing APNs key/certificate → no push in any mode (but sometimes foreground works through FCM fallback).
Not calling
FirebaseMessaging.instance.requestPermission()on iOS 10+.
// iOS requires explicit permission request
final settings = await FirebaseMessaging.instance.requestPermission(
alert: true,
badge: true,
sound: true,
);
- Provisional notifications. iOS 12+ has provisional (quiet) notifications that do not show banners — the user may think notifications are not working.
Q13: App works on emulator but crashes on real device — causes?
What the interviewer is REALLY testing:
Do you understand the differences between emulator and device environments?
Answer:
- Missing permissions at runtime. Emulators often auto-grant permissions. Real devices require runtime requests.
// Must request at runtime on real devices
final status = await Permission.camera.request();
if (!status.isGranted) { /* handle denial */ }
Architecture mismatch. Emulator runs x86/x86_64. Real device runs ARM. Native libraries (.so) compiled only for x86 will crash on ARM.
No internet permission in manifest (Android). Some emulators bypass this. Real devices enforce it.
SSL/certificate issues. Emulator may trust all certs or connect to localhost. Real device cannot reach
10.0.2.2(emulator-specific host alias).Memory constraints. Emulator has generous RAM. Real device (especially low-end) may OOM on large image processing.
GPS/sensors not available. Code that accesses accelerometer, gyroscope, or GPS without null checks crashes on devices that lack those sensors.
Screen size/density differences. Emulator has a fixed size. Real device may have a notch, rounded corners, or extreme aspect ratio causing layout overflow.
File system differences. Emulator's file system may be more permissive. Real device may have scoped storage restrictions (Android 11+).
Background execution limits. Emulator may allow unlimited background work. Real device has battery optimization that kills services.
OS version differences. Emulator might run API 34. Real device might be on API 26 with different behaviors.
Q14: Your BLoC listener fires twice — why?
What the interviewer is REALLY testing:
Do you understand BLoC stream mechanics and common setup mistakes?
Answer:
Cause 1: BLoC is provided above a widget that rebuilds, creating a new BLoC each time.
// BROKEN — new Bloc created on every build
Widget build(BuildContext context) {
return BlocProvider(
create: (_) => MyBloc(), // New BLoC every rebuild!
child: MyScreen(),
);
}
// FIXED — provide higher up, or use BlocProvider.value
Cause 2: Multiple BlocListeners for the same BLoC.
// Accidentally nested two listeners
BlocListener<MyBloc, MyState>(
listener: (ctx, state) { /* fires once */ },
child: BlocListener<MyBloc, MyState>( // DUPLICATE
listener: (ctx, state) { /* fires again */ },
child: MyWidget(),
),
)
Cause 3: BLoC emits the same state twice.
By default, bloc uses == to deduplicate consecutive states. If your state class does not override ==, every emission is "different" even if the values are the same.
// BROKEN — default == checks identity, so every emission triggers listener
class MyState {
final String data;
MyState(this.data);
}
// FIXED — override == and hashCode (or use Equatable)
class MyState extends Equatable {
final String data;
const MyState(this.data);
@override
List<Object?> get props => [data];
}
Cause 4: Using BlocBuilder + BlocListener separately instead of BlocConsumer.
Not a direct cause of double-fire, but sometimes the listener placement causes confusion.
Cause 5: Hot reload re-registers the listener.
After hot reload, initState does not re-run, but the widget tree rebuilds. If the listener is in the build method, it may re-register.
Q15: context.read inside build causes issues but context.watch doesn't — explain.
What the interviewer is REALLY testing:
Do you understand the difference between one-time reads and reactive subscriptions in Provider?
Answer:
// context.watch — REACTIVE. Subscribes to changes. Rebuilds when the value changes.
// CORRECT in build():
Widget build(BuildContext context) {
final count = context.watch<Counter>().value; // Rebuilds when count changes
return Text('$count');
}
// context.read — ONE-TIME read. Does NOT subscribe. Does NOT trigger rebuilds.
// WRONG in build():
Widget build(BuildContext context) {
final count = context.read<Counter>().value; // Reads once, NEVER updates
return Text('$count'); // Shows stale value forever
}
Why context.read in build is wrong:
-
buildis called to produce the current UI for the current state. -
context.readreads the value but does not register a dependency. When the value changes, this widget is NOT rebuilt → stale UI. - Provider's
context.read(and Riverpod'sref.read) explicitly throw in debug mode if used insidebuildto prevent this mistake.
When context.read IS correct:
// In callbacks — NOT during build, but in response to user action
ElevatedButton(
onPressed: () {
context.read<Counter>().increment(); // One-time action, not a subscription
},
child: Text('Increment'),
)
The rule:
-
watch= "I need this value to build my UI. Rebuild me when it changes." -
read= "I need this value right now for a one-time action. Do not subscribe." -
select= "I need only part of this value. Rebuild only when that part changes."
// select — optimized watch
Widget build(BuildContext context) {
// Only rebuilds when .name changes, not when other User fields change
final name = context.select<UserProvider, String>((user) => user.name);
return Text(name);
}
Q16: Your app's scroll performance is terrible — how do you diagnose and fix it?
What the interviewer is REALLY testing:
Do you know how to profile Flutter apps and understand the rendering pipeline?
Answer:
Step 1: Diagnose with DevTools.
flutter run --profile # Always profile in profile mode, never debug
Open Flutter DevTools → Performance tab → look for:
- Red frames — jank (frames taking >16ms).
- Shader compilation bars — one-time cost on first render.
- Build phase too long — too many widgets rebuilding.
- Paint phase too long — complex painting operations.
Step 2: Common causes and fixes.
Cause 1: Rebuilding too many widgets.
// BROKEN — entire list rebuilds when one item changes
ListView.builder(
itemBuilder: (ctx, i) {
final item = context.watch<ItemList>().items[i]; // Watches entire list
return ListTile(title: Text(item.name));
},
)
// FIXED — each item watches only its own data
ListView.builder(
itemBuilder: (ctx, i) => ItemWidget(index: i), // Separate widget
)
class ItemWidget extends StatelessWidget {
final int index;
const ItemWidget({required this.index});
@override
Widget build(BuildContext context) {
final item = context.select<ItemList, Item>((list) => list.items[index]);
return ListTile(title: Text(item.name));
}
}
Cause 2: Expensive itemBuilder.
// BROKEN — creating complex widgets in builder
itemBuilder: (ctx, i) {
final image = processImage(data[i].rawBytes); // CPU-intensive in build!
return ComplexCard(image: image);
}
// FIXED — pre-process data, cache images
Cause 3: Not using const constructors.
// BROKEN
ListTile(leading: Icon(Icons.star), title: Text('Hello'))
// FIXED — const prevents unnecessary rebuilds
const ListTile(leading: Icon(Icons.star), title: Text('Hello'))
Cause 4: Heavy images without caching.
// Use CachedNetworkImage instead of Image.network
CachedNetworkImage(
imageUrl: url,
placeholder: (ctx, url) => const CircularProgressIndicator(),
memCacheWidth: 200, // Resize in memory to reduce GPU load
)
Cause 5: Using shrinkWrap: true on long lists. Disables virtualization — all items are built at once.
Cause 6: Missing RepaintBoundary.
// Isolate frequently changing widgets from static ones
RepaintBoundary(
child: AnimatedWidget(), // Only this repaints, not the entire list
)
SECTION 4: CODE OUTPUT / PREDICT THE OUTPUT
Q1: FutureBuilder Rebuilding Patterns
What's the output?
class MyWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
print('build called');
return FutureBuilder<String>(
future: Future.delayed(Duration(seconds: 1), () => 'Hello'),
builder: (context, snapshot) {
print('builder: ${snapshot.connectionState}');
return Text(snapshot.data ?? 'Loading...');
},
);
}
}
Answer:
If the parent rebuilds this widget, you see:
build called
builder: ConnectionState.waiting
// After 1 second:
builder: ConnectionState.done
// If parent triggers rebuild (e.g., setState higher up):
build called
builder: ConnectionState.waiting // AGAIN! New Future created!
// After 1 second:
builder: ConnectionState.done // AGAIN!
Why the trap: Every time build runs, Future.delayed(...) creates a new Future. FutureBuilder sees a different Future object and resubscribes, showing waiting again.
The fix:
class MyWidget extends StatefulWidget { ... }
class _MyWidgetState extends State<MyWidget> {
late final Future<String> _future;
@override
void initState() {
super.initState();
_future = Future.delayed(Duration(seconds: 1), () => 'Hello');
}
@override
Widget build(BuildContext context) {
return FutureBuilder<String>(
future: _future, // Same Future object across rebuilds
builder: (context, snapshot) {
print('builder: ${snapshot.connectionState}');
return Text(snapshot.data ?? 'Loading...');
},
);
}
}
Now parent rebuilds do NOT restart the Future.
Q2: setState with Async Gap
What's the output?
class CounterWidget extends StatefulWidget { ... }
class _CounterWidgetState extends State<CounterWidget> {
int count = 0;
void increment() async {
setState(() { count++; });
print('A: $count');
await Future.delayed(Duration.zero);
setState(() { count++; });
print('B: $count');
}
@override
Widget build(BuildContext context) {
print('Build: $count');
return ElevatedButton(onPressed: increment, child: Text('$count'));
}
}
Assuming the button is pressed once, what prints?
Answer:
A: 1
Build: 1
B: 2
Build: 2
Explanation:
-
setState(() { count++; })— count becomes 1, widget is marked dirty. -
print('A: $count')— printsA: 1. -
await Future.delayed(Duration.zero)— yields to the event loop. The pending rebuild runs. -
Build: 1— the framework rebuilds the widget with count = 1. - After the microtask, the code after
awaitresumes. -
setState(() { count++; })— count becomes 2, widget marked dirty again. -
print('B: $count')— printsB: 2. - The function returns, event loop processes the rebuild.
-
Build: 2— widget rebuilds with count = 2.
Key insight: await Future.delayed(Duration.zero) allows the pending frame to render between the two setStates. Without the await, both setStates would batch into a single rebuild.
Q3: Key Behavior with List Reordering
What's the output?
class ItemWidget extends StatefulWidget {
final String title;
const ItemWidget({super.key, required this.title});
@override
State<ItemWidget> createState() => _ItemWidgetState();
}
class _ItemWidgetState extends State<ItemWidget> {
late final String _initTime;
@override
void initState() {
super.initState();
_initTime = DateTime.now().millisecondsSinceEpoch.toString();
print('initState: ${widget.title}');
}
@override
Widget build(BuildContext context) {
return Text('${widget.title} - $_initTime');
}
}
// Parent widget toggles between:
// State A: [ItemWidget(title: 'Alpha'), ItemWidget(title: 'Beta')]
// State B: [ItemWidget(title: 'Beta'), ItemWidget(title: 'Alpha')]
// (No keys provided)
What happens when switching from State A to State B?
Answer:
// Initial (State A):
initState: Alpha
initState: Beta
// Display: "Alpha - 1000", "Beta - 1001"
// After switch to State B:
// NO initState calls!
// Display: "Beta - 1000", "Alpha - 1001"
The surprise: The _initTime values are swapped from what you would expect. "Beta" shows the old timestamp that was created for "Alpha."
Why: Without keys, Flutter matches widgets by position in the list, not by content. The first slot's Element/State stays put; Flutter just updates the widget property. So:
- Position 0: State object stays (with its
_initTime), butwidget.titlechanges from "Alpha" to "Beta". - Position 1: State object stays (with its
_initTime), butwidget.titlechanges from "Beta" to "Alpha". -
initStateis NOT called because the State objects are reused.
The fix — add keys:
ItemWidget(key: ValueKey('Alpha'), title: 'Alpha'),
ItemWidget(key: ValueKey('Beta'), title: 'Beta'),
Now Flutter matches by key. When the order changes, it moves the entire Element+State to the correct position. _initTime stays with the correct title.
Q4: Stream Controller — Broadcast vs Single Subscription
What's the output?
void main() async {
// Single subscription
final single = StreamController<int>();
single.stream.listen((e) => print('Single A: $e'));
// single.stream.listen((e) => print('Single B: $e')); // Would throw!
single.add(1);
single.add(2);
// Broadcast
final broadcast = StreamController<int>.broadcast();
broadcast.stream.listen((e) => print('Broadcast A: $e'));
broadcast.stream.listen((e) => print('Broadcast B: $e'));
broadcast.add(1);
broadcast.add(2);
await Future.delayed(Duration.zero);
single.close();
broadcast.close();
}
Answer:
Single A: 1
Single A: 2
Broadcast A: 1
Broadcast B: 1
Broadcast A: 2
Broadcast B: 2
Key differences revealed:
| Single Subscription | Broadcast |
|---|---|
| Only ONE listener allowed | Multiple listeners |
| Buffers events until listener attaches | Events lost if no listener at emission time |
| Pausing the listener pauses the stream | Pausing one listener does not affect others |
Default StreamController()
|
StreamController.broadcast() |
The hidden trap — broadcast does NOT buffer:
final broadcast = StreamController<int>.broadcast();
broadcast.add(1); // LOST — no listener yet!
broadcast.stream.listen((e) => print(e)); // Will never see 1
broadcast.add(2); // Prints 2
This is why BehaviorSubject from rxdart exists — it is a broadcast stream that replays the last value to new listeners.
Q5: Provider Update Notification Timing
What's the output?
class Counter with ChangeNotifier {
int _value = 0;
int get value => _value;
void increment() {
_value++;
print('Before notify: $_value');
notifyListeners();
print('After notify: $_value');
}
}
// In a widget:
Widget build(BuildContext context) {
final counter = context.watch<Counter>();
print('Build: ${counter.value}');
return ElevatedButton(
onPressed: () {
print('Tap start');
counter.increment();
counter.increment();
print('Tap end');
},
child: Text('${counter.value}'),
);
}
When the button is pressed once:
Answer:
Tap start
Before notify: 1
After notify: 1
Before notify: 2
After notify: 2
Tap end
Build: 2
Why only ONE build, not two?
notifyListeners() marks the widget as dirty, but Flutter's framework batches rebuilds. Both increment() calls happen synchronously within a single frame. The framework schedules a rebuild after the current synchronous execution completes. When build finally runs, value is already 2.
This is different from setState in one important way: setState(() { ... }) also marks dirty and batches. But with Provider, the notification mechanism goes through ChangeNotifier → _InheritedProviderScope → markNeedsBuild. The effect is the same: synchronous calls batch into one rebuild.
Q6: Widget Lifecycle Method Ordering
What's the output?
class Parent extends StatefulWidget {
@override State createState() => _ParentState();
}
class _ParentState extends State<Parent> {
@override void initState() { super.initState(); print('Parent initState'); }
@override void didChangeDependencies() { super.didChangeDependencies(); print('Parent didChangeDependencies'); }
@override void dispose() { print('Parent dispose'); super.dispose(); }
@override
Widget build(BuildContext context) {
print('Parent build');
return Child();
}
}
class Child extends StatefulWidget {
@override State createState() => _ChildState();
}
class _ChildState extends State<Child> {
@override void initState() { super.initState(); print('Child initState'); }
@override void didChangeDependencies() { super.didChangeDependencies(); print('Child didChangeDependencies'); }
@override void dispose() { print('Child dispose'); super.dispose(); }
@override
Widget build(BuildContext context) {
print('Child build');
return const SizedBox();
}
}
On first mount, what prints?
Answer:
Parent initState
Parent didChangeDependencies
Parent build
Child initState
Child didChangeDependencies
Child build
On removal from tree (e.g., navigation away):
Child dispose
Parent dispose
Key insights:
-
initState→didChangeDependencies→build— always this order for each widget. -
Parent builds before child initializes — the parent's
buildmethod creates theChildwidget, which then triggers the child'sinitState. - Disposal is bottom-up — children dispose before parents. This ensures children can still access inherited data during their disposal.
-
didChangeDependenciesis called immediately afterinitStatebecause the widget is mounting into the tree and inheriting dependencies for the first time.
Q7: Null Safety Edge Cases
What's the output?
void main() {
int? a = null;
int b = a ?? 0;
print(b); // ?
String? name;
print(name?.length); // ?
print(name?.length ?? 0); // ?
List<int>? list;
print(list?.isEmpty); // ?
list = [];
print(list?.isEmpty); // ?
int? x;
int? y;
print(x ?? y ?? 42); // ?
String? s = 'hello';
print(s?.toUpperCase()); // ?
// Does the ?. here matter since s is non-null?
}
Answer:
0
null
0
null
true
42
HELLO
Explanation:
int b = a ?? 0; // a is null → 0
print(name?.length); // name is null → null (short-circuits)
print(name?.length ?? 0); // name?.length is null → ?? returns 0
print(list?.isEmpty); // list is null → null
list = [];
print(list?.isEmpty); // list is [] → true (list is non-null now, but type is still List<int>?)
print(x ?? y ?? 42); // x is null → y ?? 42 → y is null → 42
print(s?.toUpperCase()); // s is "hello" → "HELLO" (?. is unnecessary but not harmful)
The ?. on a non-null variable: The compiler may warn that ?. is unnecessary on a variable the analyzer knows is non-null. But it is not an error — it still calls the method normally.
Q8: Isolate Message Passing
What's the output?
import 'dart:isolate';
void isolateFunction(SendPort sendPort) {
print('Isolate: start');
sendPort.send('Hello from isolate');
print('Isolate: sent');
}
void main() async {
print('Main: start');
final receivePort = ReceivePort();
await Isolate.spawn(isolateFunction, receivePort.sendPort);
print('Main: spawned');
final message = await receivePort.first;
print('Main: received "$message"');
print('Main: done');
}
Answer:
The output is non-deterministic in ordering between main and isolate, but a typical run:
Main: start
Main: spawned
Isolate: start
Isolate: sent
Main: received "Hello from isolate"
Main: done
Key insights:
-
Isolate.spawnreturns a Future that completes when the isolate is created, NOT when it finishes executing. -
Main: spawnedprints before the isolate starts becausespawnreturns quickly. - The isolate runs on a separate thread. Its
printstatements interleave with the main isolate. -
receivePort.firstawaits the first message, suspending main until the isolate sends something. -
sendPort.sendis asynchronous from the receiver's perspective — the message is queued. - After
receivePort.first, the port is automatically closed (.firstlistens for one message then cancels).
Important: You cannot send arbitrary objects between isolates. Only primitives, lists, maps, SendPort, TransferableTypedData, and objects that can be serialized are supported. You CANNOT send closures, functions, or objects with native resources.
Q9: Animation Controller Value at Different States
What's the output?
class _MyState extends State<MyWidget> with SingleTickerProviderStateMixin {
late AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: Duration(seconds: 2),
lowerBound: 0.0,
upperBound: 1.0,
);
print('Initial value: ${_controller.value}');
print('Initial status: ${_controller.status}');
_controller.forward();
print('After forward() value: ${_controller.value}');
print('After forward() status: ${_controller.status}');
_controller.addStatusListener((status) {
print('Status changed: $status');
});
}
}
Answer:
Initial value: 0.0
Initial status: AnimationStatus.dismissed
After forward() value: 0.0
After forward() status: AnimationStatus.forward
And later, after 2 seconds:
Status changed: AnimationStatus.completed
Key insights:
-
_controller.valueis still0.0immediately afterforward()— the animation has not ticked yet.forward()just schedules the animation to start on the next frame. -
_controller.statusisforwardimmediately — the status changes synchronously when you callforward(), even though no frames have ticked. -
The status listener is added AFTER
forward()— so it will NOT fire for thedismissed → forwardtransition. It will only fire when the animation completes (forward → completed). - If you want to catch all status changes, add the listener BEFORE calling
forward().
Value progression (each frame):
Frame 0: 0.0
Frame 1: ~0.008 (depends on frame rate)
Frame 2: ~0.016
...
Frame 120 (at 60fps × 2s): 1.0
Status: completed
Q10: BuildContext Availability in Callbacks
What's the output?
class MyWidget extends StatefulWidget { ... }
class _MyWidgetState extends State<MyWidget> {
@override
void initState() {
super.initState();
// Can we use context here?
print('initState mounted: $mounted');
// print(Theme.of(context)); // Does this work?
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
print('didChangeDependencies mounted: $mounted');
print('Theme accessible: ${Theme.of(context).brightness}');
}
@override
Widget build(BuildContext context) {
return ElevatedButton(
onPressed: () async {
final navigator = Navigator.of(context);
await Future.delayed(Duration(seconds: 2));
// Is context safe here?
print('After delay mounted: $mounted');
if (mounted) {
navigator.push(MaterialPageRoute(builder: (_) => OtherScreen()));
}
},
child: const Text('Navigate'),
);
}
}
Answer:
initState mounted: true
didChangeDependencies mounted: true
Theme accessible: Brightness.light // (or dark, depending on theme)
When button is pressed and after 2 seconds:
After delay mounted: true // (or false if navigated away)
Key rules about context availability:
| Lifecycle Method |
context safe? |
Notes |
|---|---|---|
initState |
Technically yes, but don't use of(context)
|
context exists but InheritedWidget lookups are unreliable. The widget is not yet fully inserted into the tree. Framework will warn. |
didChangeDependencies |
Yes — this is the right place | First safe place to call Theme.of(context), MediaQuery.of(context), etc. |
build |
Yes | Standard usage. |
dispose |
No for ancestor lookups | Widget is being removed. Ancestors may be gone. |
async callbacks |
Maybe — check mounted
|
The widget may have been disposed during the await. |
The initState + context trap:
@override
void initState() {
super.initState();
// This "works" but is WRONG:
// final theme = Theme.of(context); // Triggers dependOnInheritedWidgetOfExactType during initState
// Do this instead:
WidgetsBinding.instance.addPostFrameCallback((_) {
final theme = Theme.of(context); // Safe — first frame has completed
});
}
// Or use didChangeDependencies:
@override
void didChangeDependencies() {
super.didChangeDependencies();
final theme = Theme.of(context); // Correct place
}
Q11 (Bonus): What does this print?
void main() async {
print('1');
Future(() => print('2'));
Future.microtask(() => print('3'));
await Future.delayed(Duration.zero, () => print('4'));
print('5');
Future(() => print('6'));
Future.microtask(() => print('7'));
}
Answer:
1
3
2
4
5
7
6
Explanation (Dart event loop):
-
print('1')— synchronous, prints immediately. -
Future(() => print('2'))— schedulesprint('2')on the event queue (low priority). -
Future.microtask(() => print('3'))— schedulesprint('3')on the microtask queue (high priority). -
await Future.delayed(Duration.zero, ...)—awaityields to the event loop. Before processing the event queue, Dart drains the microtask queue:- Microtask:
print('3')→ prints 3. - Event queue:
print('2')→ prints 2. - Then
Future.delayed(Duration.zero)resolves: prints 4.
- Microtask:
-
awaitresumes.print('5')— prints 5. -
Future(() => print('6'))— schedules on event queue. -
Future.microtask(() => print('7'))— schedules on microtask queue. -
mainreturns. Dart drains microtask queue first: prints 7. Then event queue: prints 6.
The rule: Microtasks always run before the next event. await yields to both queues.
Q12 (Bonus): What happens here?
void main() {
try {
Future.error('async error');
print('No crash');
} catch (e) {
print('Caught: $e');
}
}
Answer:
No crash
Then, after the synchronous code completes, the Dart runtime reports an unhandled async error (which in Flutter shows a red screen or logs to console).
Why? try-catch only catches synchronous exceptions. Future.error() creates a Future that completes with an error — this error lives in the async world. The catch block never sees it because no synchronous exception was thrown.
To catch it:
// Option 1: await the Future
try {
await Future.error('async error');
} catch (e) {
print('Caught: $e'); // Now it works
}
// Option 2: Use .catchError
Future.error('async error').catchError((e) => print('Caught: $e'));
// Option 3: Zone-level error handling
runZonedGuarded(() {
Future.error('async error');
}, (error, stack) {
print('Zone caught: $error');
});
Top comments (0)