Dart Interview Questions and Answers
Last updated:
Check out 40 of the most common Dart interview questions, then take an AI-powered practice interview
Q1What is Dart and why was it created?
BasicFundamentals
Answer
Dart is a client-optimised, statically-typed, object-oriented programming language developed by Google. Originally announced in 2011 as a JavaScript alternative for the browser, it pivoted in 2018 when Flutter shipped and became the language's killer app. Dart was designed to feel familiar to anyone who knows Java, C#, or JavaScript while solving problems specific to building UI: hot-reload friendly compilation, a garbage collector tuned for short-lived UI objects, sound static types that catch bugs at compile time, and the ability to compile to native ARM/x64 (for mobile), JavaScript (for web), and self-contained executables (for desktop).
It has two compilation modes, JIT for development hot-reload and AOT for production builds, which is core to why Flutter feels so fast both to develop in and to ship. A follow-up you should expect is what actually differs between the two modes. JIT builds (`dart run`, `flutter run` in debug) keep the VM and the compiler resident in the process, which is what makes hot reload possible, and also why timing anything in a debug build is meaningless: debug Flutter is several times slower than release.
AOT builds (`flutter build apk --release`, `dart compile exe`) snapshot machine code ahead of time, so there is no warm-up, no `dart:mirrors`, and no runtime code loading. Since Dart 3.0 (May 2023) the language is fully null safe with no legacy mode left, so a package that never migrated will not even resolve on a current SDK. The other thing worth naming is that Dart owns its whole toolchain: `dart pub`, `dart analyze`, `dart format`, `dart test` and `dart compile` all ship inside one SDK, so there is no build-tool assembly step before you can write code.
Key Points
- Statically typed with sound type system (after Dart 2.12 null safety)
- Compiles to native code (AOT) and JavaScript (dart2js / dart2wasm)
- JIT mode powers Flutter hot reload during development
- Single-threaded event loop with isolates for parallelism
Q2What is sound null safety in Dart and why does it matter?
BasicNull Safety
Answer
Sound null safety, added in Dart 2.12 (2021), means the type system distinguishes between types that can be null and types that cannot. A variable of type `String` can never be null; only `String?` can. The compiler enforces this, you cannot pass a nullable to a non-nullable position without explicit handling.
'Sound' means the guarantee holds at runtime too: there is no escape hatch where a `String` could secretly be null at execution time (unlike Kotlin's platform types or TypeScript's `strict: false`). The payoff is huge in practice: the entire class of `NullPointerException` bugs that haunt Java and pre-2021 Dart code mostly disappears, and the compiler can optimise away null checks for non-nullable types. Two follow-ups come up constantly.
First, what `late` costs you: `late String name;` compiles fine, but reading it before assignment throws `LateInitializationError` at runtime, so `late` deliberately trades a compile-time guarantee for a runtime one. Second, where soundness still leaks. `json['name'] as String` and the `!` operator both defer to a runtime check, so they move the failure rather than removing it. A `List<String>` coming back from a platform channel arrives as `List<dynamic>`, and `.cast<String>()` gives you a lazy view that throws `TypeError` at the element you touch rather than at the boundary, which is why `List<String>.from(raw)` is usually the better choice. Since Dart 3.0 there is no mixed-mode migration story left at all: the old `dart migrate` tool and the unsound runtime are gone, so an unmigrated dependency is a hard blocker rather than a warning you can defer.
String greet(String name) => 'Hello, $name';
String? maybeName;
// greet(maybeName); // compile error: String? is not assignable to String
// Three ways to handle the nullable:
greet(maybeName ?? 'guest'); // null-coalescing
if (maybeName != null) greet(maybeName); // flow analysis promotes to String
greet(maybeName!); // bang operator: I promise it's not null (runtime check)
Key Points
- `String` is non-nullable; `String?` is nullable
- Flow analysis promotes `String?` to `String` after a null check
- The `!` operator asserts non-null and throws at runtime if wrong
- Late initialization (`late`) for non-nullable fields you'll set before first use
Q3What's the difference between `final`, `const`, and `var` in Dart?
BasicVariables
Answer
All three relate to variable mutability and timing. `var` declares a mutable variable whose type is inferred from the initializer. `final` declares a variable that can only be assigned once, but its value is computed at runtime, useful for things like `final timestamp = DateTime.now();`. `const` declares a compile-time constant, the value must be knowable at compile time (literals, const constructors). The crucial distinction in Flutter: `const` widgets are cached and reused across rebuilds, which is a major performance optimization. `final` widgets get rebuilt every time. The Flutter linter literally warns you to use `const` constructors where possible because of this.
A senior follow-up is what `const` actually means at runtime. Const objects are canonicalised, so two `const Point(1, 2)` expressions produce the same instance and `identical(const Point(1, 2), const Point(1, 2))` is `true`; the value lives in the binary rather than being allocated on each rebuild. `const` also propagates: a const constructor call whose arguments are all const is itself const, which is why the `prefer_const_constructors` and `prefer_const_literals_to_create_immutables` lints in `analysis_options.yaml` can promote whole widget subtrees at once. Two gotchas interviewers like: `final` freezes only the reference, so `final list = [1, 2]; list.add(3);` compiles and runs, while mutating a `const []` throws `Unsupported operation: Cannot add to an unmodifiable list`. And a `const` constructor requires every field to be `final`, so adding one mutable field to a widget silently removes `const` from every call site.
var count = 0; // mutable, type inferred as int
count = 1;
final user = fetchUser(); // assigned once, runtime value
// user = otherUser; // compile error
const pi = 3.14159; // compile-time constant
const widget = SizedBox(height: 8); // cached, reused across rebuilds
Q4What are the differences between `List`, `Set`, and `Map` in Dart?
BasicCollections
Answer
`List<T>` is an ordered, indexable collection, Dart's equivalent of an array. By default it's growable, but `List.unmodifiable` and `const []` give you immutable versions. `Set<T>` is an unordered collection of unique elements; uniqueness is determined by `==` and `hashCode`. `Map<K, V>` is a key-value collection where keys are unique (again by `==`/`hashCode`). All three have literal syntax: `[1, 2, 3]`, `{1, 2, 3}`, and `{'a': 1, 'b': 2}`.
A subtle gotcha: `{}` is an empty Map, not an empty Set, to get an empty Set you write `<int>{}` or `Set<int>()`. All three support spread (`...`), if-elements, and for-elements in their literals, which is unusually expressive compared to other languages. The performance details get probed too. `List` indexing is O(1) but `list.contains` and `list.remove` are O(n), while `Set.contains` and `Map[key]` are amortised O(1), so replacing a `List` with a `Set` is the single most common fix for a loop that turned out to be quadratic on a large catalogue.
The default map literal builds a `LinkedHashMap`, so iteration order is insertion order and you can rely on it; `SplayTreeMap` gives you sorted keys, `HashMap` gives you no order guarantee at all. If you put a custom class in a `Set` or use it as a `Map` key you must override both `==` and `hashCode` or lookups will silently miss, since the default is identity. Dart 3 records give you that structural equality for free, so `(String, int)` works as a composite key with no boilerplate. Also note `List.filled(3, [])` shares one list across all three slots, a classic aliasing bug; use `List.generate(3, (_) => [])`.
final scores = [10, 20, 30]; // List<int>
final tags = {'flutter', 'dart', 'mobile'}; // Set<String>
final user = {'name': 'Asha', 'age': 27}; // Map<String, dynamic>
// Spread + if-element in collection literals:
final items = [
'home',
if (user['isAdmin'] == true) 'settings',
...defaultItems,
];
Q5How does string interpolation work in Dart?
BasicStrings
Answer
Dart uses `$variable` for simple interpolation and `${expression}` for arbitrary expressions inside double-quoted strings. The expression form runs `toString()` on the result. Multi-line strings use triple-quotes.
Raw strings (prefix `r`) disable interpolation, useful for regex patterns. Strings are immutable and the `+` operator works, but for performance-critical hot paths (like building large JSON), prefer `StringBuffer` to avoid allocating intermediate strings. The details that separate a careful answer from a shallow one: `$name` only works for a bare identifier, so `$user.name` interpolates `user` and then appends the literal text `.name`, producing log lines like `Instance of 'User'.name`; you need `${user.name}`.
Interpolation calls `toString()`, so any model without a `toString` override renders as `Instance of 'Foo'`, which is why `@override String toString()` on domain classes pays for itself in incident debugging. Adjacent string literals concatenate with no operator at all, so `'part one ' 'part two'` is one string, which is how long messages get wrapped across lines. Dart has no character type: `'abc'[0]` returns a one-character `String`, and because strings are sequences of UTF-16 code units, `length` counts code units, not user-visible characters, so emoji and Devanagari text give surprising results; use `package:characters` and `text.characters.length` when you need grapheme clusters. Dart 3.6 also added digit separators, so `1_000_000` is a legal literal inside an interpolated expression.
final name = 'Asha';
final items = ['a', 'b', 'c'];
print('Hello, $name'); // Hello, Asha
print('Items: ${items.length}'); // Items: 3
print('First: ${items.first.toUpperCase()}'); // First: A
final regex = r'\d+'; // raw, no $/\ escaping needed
final block = '''
Line one
Line two
''';
Q6What is the difference between named and positional parameters?
BasicFunctions
Answer
Positional parameters come in order: `greet('Asha', 27)`. Named parameters are passed by name: `greet(name: 'Asha', age: 27)`. In Dart you mark parameters as named by wrapping them in `{}` in the signature.
Named parameters can be required (with the `required` keyword) or optional (with a default value). This is the convention all over Flutter, `Container(width: 100, height: 50, color: Colors.red)` reads as a configuration object rather than guessing-the-order. Optional positional parameters use `[]` and are mostly legacy; new APIs almost always use named parameters because they're far more readable at call sites with three or more arguments.
The mechanics interviewers check: a named parameter is optional by default, so `String? subtitle` needs nothing extra, but a non-nullable named parameter must be either marked `required` or given a default, otherwise the analyzer reports that the parameter can't have a value of null because of its type. Default values must be compile-time constants, which is why you see `List<String> tags = const []` rather than `= []`. You cannot mix optional positional (`[]`) and named (`{}`) parameters in the same signature.
Two Flutter-specific points: Dart 2.17 super-initializer parameters let you write `MyWidget({super.key})` instead of the old `MyWidget({Key? key}) : super(key: key)`, and adding a new `required` named parameter to a public widget is a breaking change for every call site, so teams usually ship it as optional with a default first and tighten it in a later major version. Dart 3.7 wildcard variables also let you write `_` for a callback parameter you deliberately ignore without shadowing anything.
// Named parameters, Flutter style
Widget card({
required String title,
String? subtitle,
Color color = Colors.white,
VoidCallback? onTap,
}) => /* ... */;
card(title: 'Hello', color: Colors.blue, onTap: () => print('tapped'));
// Optional positional (legacy style)
String greet(String name, [int age = 0]) => 'Hi $name, $age';
Q7What are arrow functions in Dart?
BasicFunctions
Answer
Arrow syntax (`=>`) is a shorthand for a function whose body is a single expression. `int double(int x) => x * 2;` is exactly equivalent to `int double(int x) { return x * 2; }`. The arrow form cannot contain statements, only an expression. It is heavily used in Flutter callbacks (`onPressed: () => print('tapped')`) and in `map`/`where`/`reduce` style iterator chains where multi-line lambdas would be visually noisy.
Two things trip candidates up. First, the arrow implicitly returns the expression value, so `void log() => print('x')` is legal even though `print` returns void, but a block body `() { save(); }` returns `null`, which matters when the expected type is `Future<void> Function()`: the caller awaits a `null` and the work is never waited on. Second, `async` combines with arrows, so `Future<int> load() async => fetch();` is valid, but in Flutter `onPressed: () async => await save()` produces a `Future` nobody holds, so an exception inside `save()` becomes an uncaught async error routed to `PlatformDispatcher.instance.onError` rather than something your `try` block sees.
Arrows also cannot hold statements, so no `try/catch`, no `if` statement, no `await for`, and any real error handling forces you back to a block body. A conditional expression is still an expression, which is why so much Flutter code reads as `=>` plus a ternary, and the `prefer_expression_function_bodies` lint pushes single-return methods into arrow form.
final nums = [1, 2, 3, 4];
final doubled = nums.map((n) => n * 2).toList(); // [2, 4, 6, 8]
final evens = nums.where((n) => n.isEven).toList(); // [2, 4]
final sum = nums.fold(0, (acc, n) => acc + n); // 10
Q8How do you handle errors in Dart with try/catch?
BasicError Handling
Answer
Dart's `try/catch/finally` is similar to Java's. You can catch by type (`on FormatException catch (e)`) or generically (`catch (e, stackTrace)`). `rethrow` preserves the original stack trace, which is critical for debugging, never use `throw e` to re-raise. `finally` always runs, even on `return` or `throw`. Dart distinguishes between `Error` (programmer bugs, assertion failures, type errors, you shouldn't catch them) and `Exception` (recoverable conditions like network failures, you should catch them).
In async code, errors propagate through Futures and are caught by `try/catch` around `await` or `.catchError()` on the Future. The points a senior interviewer pushes on: `catch (e)` with a single parameter throws away the stack trace, so always write `catch (e, st)` and pass `st` to your logger, or use `Error.throwWithStackTrace(e, st)` when you re-raise from a different frame. An error thrown inside a Future that nobody awaits is not caught by any enclosing `try`; it goes to the current `Zone`, so a Flutter app needs both `FlutterError.onError` for framework errors and `PlatformDispatcher.instance.onError` (or `runZonedGuarded`) for uncaught async errors, wired into Crashlytics or Sentry. `Future.wait` throws on the first failure and discards the successful results, so for partial success you attach a `catchError` to each future first. Two more: `assert` statements are stripped entirely from release builds, so never put a side effect inside one, and `on Error catch` will happily swallow real bugs such as `TypeError` and `RangeError`, which is why a bare `catch (e)` around a widget build usually hides the defect rather than handling it.
Future<User> loadUser(String id) async {
try {
final res = await http.get(Uri.parse('/users/$id'));
return User.fromJson(jsonDecode(res.body));
} on FormatException catch (e) {
log('Bad JSON', error: e);
rethrow; // preserves stack trace, don't `throw e`
} on http.ClientException catch (e) {
throw NetworkError('Failed to load user $id', cause: e);
} finally {
log('loadUser($id) finished');
}
}
Q9What are getters and setters in Dart?
BasicOOP
Answer
Getters and setters are special methods that look like field access at the call site. `class Circle { double radius; double get area => pi * radius * radius; }` lets callers write `circle.area` (no parentheses) even though `area` is computed. Setters use `set name(value)` syntax. All fields in Dart actually get implicit getters and setters generated, that's why you can override field access by adding an explicit getter without changing the API.
This is heavily used in immutable models (only getters, no setters) and in reactive frameworks where the setter triggers a notification. Details worth knowing: a getter and a setter with the same name form one property, so declaring only the getter makes the value read-only to callers while a private backing field stays writable inside the library, which is the standard way to expose immutable state from a controller. Getters run on every access and are never cached, so `double get total => items.fold(0, ...)` inside a `build` method recomputes on every frame; cache it in a `late final` field or compute it once in the model.
Overriding an inherited field with a getter in a subclass is legal precisely because every field already has an implicit getter, which is how `mocktail` stubs properties and how Flutter widgets expose `key`. A setter must return `void` and take exactly one parameter, so it cannot report failure by return value; throw `ArgumentError` or `StateError` instead. Finally keep getters total and cheap, because one that throws will blow up inside `toString`, `==`, or the Flutter inspector at the worst possible moment.
class Temperature {
double _celsius;
Temperature(this._celsius);
double get celsius => _celsius;
double get fahrenheit => _celsius * 9 / 5 + 32;
set celsius(double value) {
if (value < -273.15) throw ArgumentError('below absolute zero');
_celsius = value;
}
}
final t = Temperature(20);
print(t.fahrenheit); // 68.0, looks like a field
t.celsius = 100; // triggers setter validation
Q10What is the difference between `==` and `identical()` in Dart?
BasicEquality
Answer
`==` calls the operator overload, by default it's identity-based (same as `identical()`), but most classes override it for value equality. `identical(a, b)` checks reference identity, are these literally the same object in memory. For primitive types (int, double, String, bool), Dart canonicalises literals, so `identical('foo', 'foo')` is `true` and `identical(1, 1)` is `true`. When you override `==`, you MUST also override `hashCode`, otherwise objects equal by `==` get different hashes, breaking Sets and Maps.
Dart 3.0+ records auto-implement structural equality so you usually don't need to override these for simple data classes anymore. The contract is what gets probed: `==` must be reflexive, symmetric, transitive and consistent with `hashCode`, and breaking the last clause means a `Set` will hold two objects that compare equal or a `Map` lookup will miss a key that is present. Use `Object.hash(a, b)` or `Object.hashAll(list)` rather than summing or XOR-ing fields by hand, because hand-rolled hashes collide badly on small integers. `NaN` is the classic exception: `double.nan == double.nan` is `false` even though the two can be the same object.
Const canonicalisation cuts the other way, `identical(const Point(1, 1), const Point(1, 1))` is `true` while the non-const forms are not, which is exactly the `const` optimisation Flutter relies on. In Flutter this matters for rebuilds: the framework compares the old and new widget with `runtimeType` and `key`, and overriding `==` on a widget so two different configurations compare equal can make the framework skip an update you needed. `package:equatable` and `freezed` generate the pair for you.
class Point {
final int x, y;
const Point(this.x, this.y);
@override
bool operator ==(Object other) =>
other is Point && x == other.x && y == other.y;
@override
int get hashCode => Object.hash(x, y);
}
print(Point(1, 2) == Point(1, 2)); // true
print(identical(Point(1, 2), Point(1, 2))); // false
Q11What is the `dynamic` type and when should you use it?
BasicType System
Answer
`dynamic` is Dart's escape hatch from static typing, a `dynamic` variable can hold any value and any method call on it compiles (it's checked at runtime). Use it sparingly: parsing untyped JSON, interop with JavaScript on web, or generic containers. The dangerous lookalike is `var`, `var` infers a type and is fully static; `dynamic` defers all type checking.
A common Flutter bug: `final data = jsonDecode(body);` makes `data` a `dynamic` (actually `Map<String, dynamic>`), and `data['count']` is also dynamic, so the compiler won't catch `data['count'].whatever()`. Best practice is to deserialize into a typed model class as early as possible. The distinctions interviewers ask for explicitly: `dynamic` disables static checking but not runtime checking, so a missing method throws `NoSuchMethodError` and assigning a `dynamic` into a typed variable inserts an implicit downcast that throws `TypeError` at that assignment. `Object?` is the safe alternative, it accepts anything but forces a cast or a pattern match before you can call anything.
The practical fix on a legacy codebase is configuration rather than discipline: set `strict-casts: true` under `analyzer: language:` in `analysis_options.yaml` to turn every implicit `dynamic` to typed assignment into an analyzer error, add `strict-raw-types: true` to ban bare `List` and `Map` without type arguments, and enable the `avoid_dynamic_calls` lint. Also note that `List<dynamic>` from `jsonDecode` will not assign to `List<String>`: `.cast<String>()` produces a lazy view that throws on the offending element much later, while `List<String>.from(raw)` fails immediately at the parse boundary where the stack trace is still useful.
dynamic value = 'hello';
value.foo(); // compiles, throws NoSuchMethodError at runtime
value = 42;
value.toRadixString(16); // works
// vs. Object, also accepts anything, but you must cast to call methods
Object x = 'hello';
// x.length; // compile error
(x as String).length; // OK
Q12What is the Dart event loop and microtask queue?
BasicConcurrency
Answer
Dart is single-threaded inside a given isolate, scheduled by an event loop. There are two queues: the event queue (I/O, timers, user input, Future completions) and the microtask queue (higher-priority tasks scheduled via `scheduleMicrotask` or completed synchronously by `Future.microtask`). The loop drains all microtasks before pulling the next event off the event queue.
This matters because if you flood the microtask queue, the event queue never gets a chance, UI freezes, network callbacks queue up. In practice: most `Future`s land on the event queue; `await` continuations after a synchronously-completed Future land on the microtask queue. For long compute work, you don't want either queue, you want an `Isolate`.
Interviewers usually test this with an ordering puzzle, so know the rules exactly. `scheduleMicrotask` and `Future.microtask` enqueue on the microtask queue. `Future(() {})`, `Future.delayed`, `Timer`, socket reads and platform-channel replies enqueue on the event queue. `Future.value(1).then(cb)` schedules `cb` as a microtask even though the value already exists, so it still runs after the current synchronous block finishes, and `await` on an already-completed Future still costs at least one microtask turn, which is why nothing after an `await` ever runs synchronously. A microtask that schedules another microtask starves the event queue permanently, and the symptom is a frozen UI with no exception and no crash report, unlike an infinite `while` loop which at least shows up in the CPU profiler. In Flutter, `SchedulerBinding.instance.addPostFrameCallback` is the hook that runs after the current frame is built, which is the correct place to trigger work that needs a laid-out tree such as scrolling to an item or showing a dialog after `initState`.
import 'dart:async';
void main() {
print('1 sync');
Future(() => print('5 event queue')); // event queue (zero-duration timer)
Future.microtask(() => print('3 microtask')); // microtask queue
scheduleMicrotask(() => print('4 microtask')); // microtask queue
Future.value('x').then((_) => print('4b then'));// already complete -> microtask
Timer.run(() => print('6 timer')); // event queue, after the Future above
print('2 sync');
}
// 1 sync
// 2 sync
// 3 microtask
// 4 microtask
// 4b then
// 5 event queue
// 6 timer
Key Points
- Single-threaded inside an isolate
- Microtask queue drains completely before next event-queue item
- Don't put long CPU work on either queue, use Isolate.run
Q13How do constructors work in Dart: default, named, factory, const, and redirecting?
BasicOOP
Answer
A Dart class gets one unnamed constructor plus any number of named ones, so you write `Money(this.paise)` and `Money.zero()` side by side. The `this.field` shorthand assigns a parameter straight to a field before the body runs. The initializer list after the colon runs before the constructor body and before the superclass body, and it is the only place you can initialise a `final` field from a computed value or run an `assert`.
You cannot reference `this` inside an initializer list because the object is not fully constructed yet, and that single restriction is why the `late` keyword exists. The execution order interviewers ask for is: the subclass initializer list, then the superclass constructor, then the subclass body. A `factory` constructor is different in kind: it is not required to create a new instance, so it can return a cached object, return a subtype, or throw. `factory User.fromJson(Map<String, dynamic> json)` is the everyday example and singletons are usually `factory Logger() => _instance`.
Because a factory has no initializer list it cannot use `this.field` parameters and cannot be `const`. A `const` constructor requires every field to be `final` and lets callers write `const Money(0)`, which canonicalises the instance at compile time. Redirecting constructors forward to another constructor in the same class with `Foo.named() : this(...)` and must have an empty body. Dart 2.17 super-initializer parameters replaced the old `MyWidget({Key? key}) : super(key: key)` boilerplate with `MyWidget({super.key})`.
class Money {
final int paise;
final String currency;
const Money(this.paise, {this.currency = 'INR'})
: assert(paise >= 0, 'paise must be non-negative');
const Money.zero() : this(0); // redirecting, empty body
factory Money.parse(String input) { // may throw or return a cached value
final rupees = double.tryParse(input);
if (rupees == null) throw FormatException('bad amount: $input');
return Money((rupees * 100).round());
}
Money.fromJson(Map<String, dynamic> json)
: paise = json['paise'] as int,
currency = json['currency'] as String? ?? 'INR';
}
const free = Money.zero(); // canonicalised at compile time
print(identical(free, const Money.zero())); // true
Key Points
- Initializer list runs before the superclass constructor and before the body
- `factory` can return a cached instance or a subtype, and cannot be `const`
- `const` constructors require every field to be `final`
- `super.key` (Dart 2.17) replaced the old `super(key: key)` boilerplate
Q14What does the `dart` CLI give you, and how do `dart analyze`, `dart fix`, and `analysis_options.yaml` work together?
BasicTooling
Answer
The SDK ships one CLI that covers the whole loop: `dart create -t console my_app` scaffolds, `dart run` executes on the JIT VM, `dart compile exe|aot-snapshot|js` produces release output, `dart test` runs `package:test`, `dart pub get|upgrade|outdated` resolves `pubspec.yaml`, and `dart doc` builds API docs. In a Flutter package the `flutter` CLI wraps most of these, so you run `flutter pub get` and `flutter test`, but you still invoke generators with `dart run build_runner build --delete-conflicting-outputs`. The static analysis story is the part interviewers actually care about. `analysis_options.yaml` at the package root configures the analyzer that both the IDE and CI use.
You start with `include: package:flutter_lints/flutter.yaml` (or `package:lints/recommended.yaml` for pure Dart), add rules under `linter: rules:`, and set language options under `analyzer: language:`. Two settings do most of the work: `strict-casts: true` bans implicit downcasts from `dynamic`, and `strict-raw-types: true` bans bare `List` without a type argument. The `errors:` section lets you promote any lint to a hard error so the build fails, which is how you make `unawaited_futures` and `use_build_context_synchronously` non-negotiable on a team. `dart analyze --fatal-infos` runs that same analysis headlessly and exits non-zero, which is what belongs in CI. `dart fix --dry-run` lists the mechanically applicable fixes and `dart fix --apply` rewrites the files, which is how you adopt a new lint across a large repository in one commit. `dart format --output=none --set-exit-if-changed .` enforces formatting without rewriting in CI.
# analysis_options.yaml
include: package:flutter_lints/flutter.yaml
analyzer:
language:
strict-casts: true
strict-raw-types: true
errors:
unawaited_futures: error
use_build_context_synchronously: error
todo: ignore
exclude:
- "**/*.g.dart"
- "**/*.freezed.dart"
linter:
rules:
- prefer_const_constructors
- avoid_dynamic_calls
- always_declare_return_types
# CI gate:
# dart analyze --fatal-infos
# dart format --output=none --set-exit-if-changed .
# flutter test --coverage
Q15Explain Futures and how async/await works in Dart.
IntermediateAsync
Answer
A `Future<T>` represents a value that will exist later, Dart's equivalent of a JavaScript Promise. Marking a function `async` makes it return a `Future`, and inside an async function you use `await` to pause until another Future completes. The compiler rewrites your async function as a state machine: each `await` becomes a continuation.
Crucially, `await` does NOT block the thread, control returns to the event loop, which keeps the UI responsive in Flutter. The most common bug is forgetting to `await` a Future: `foo();` instead of `await foo();` schedules the work but moves on immediately, often with a runtime error like 'Tried to use disposed widget'. The analyzer's `unawaited_futures` lint catches most of these.
The follow-ups that separate levels: an `async` function starts executing synchronously up to its first `await`, so side effects before that await happen immediately, and an exception thrown before the first `await` is still delivered as a failed Future rather than thrown synchronously at the call site. `Future.wait` fails fast on the first error and you lose the other results, so for partial success you map each future through `catchError` first, and `Future.any` gives you hedged requests where the first responder wins. `Future.delayed` inside a widget is not cancellable, so a pending timer will still fire after the user navigates away; hold a `Timer` and cancel it, or check a flag. The guard everyone forgets in Flutter is `if (!mounted) return;` after every `await` before touching `context` or calling `setState`, otherwise you get an exception saying the widget has been unmounted. Flutter 3.7 added `context.mounted` for the `BuildContext` case and the `use_build_context_synchronously` lint flags the miss, which is worth promoting to an error in `analysis_options.yaml`.
Future<User> fetchUser(String id) async {
final res = await http.get(Uri.parse('/users/$id'));
if (res.statusCode != 200) throw HttpException('${res.statusCode}');
return User.fromJson(jsonDecode(res.body));
}
// Concurrent fetches, much faster than sequential awaits
final results = await Future.wait([
fetchUser('1'),
fetchUser('2'),
fetchUser('3'),
]);
// Fire-and-forget, explicit `unawaited` silences the lint
unawaited(analytics.log('screen_view'));
Key Points
- `async` functions return Futures; `await` suspends without blocking
- Use `Future.wait` for concurrent operations (much faster than sequential await)
- Forgotten `await` is the #1 async bug, enable the `unawaited_futures` lint
Q16What are Streams in Dart and how do they differ from Futures?
IntermediateAsync
Answer
A `Future<T>` produces a single value (or error) once. A `Stream<T>` produces zero or more values over time, plus optionally an error and a done signal. Streams come in two flavors: single-subscription (one listener for the lifetime of the stream, typical for file reads or HTTP response bodies) and broadcast (multiple listeners, typical for UI events, BLoC state, or Firebase document listeners).
Consume with `await for (final value in stream)` inside an async function, or with `stream.listen((value) {...})` for callback style. In Flutter, `StreamBuilder` is the standard way to render a stream as a widget. Heavy hitters: `package:rxdart` adds Rx operators like `debounceTime` and `combineLatest` for reactive state pipelines.
The mechanics to be precise about: a single-subscription stream throws `Bad state: Stream has already been listened to` on a second `listen`, which is the most common Stream error in Flutter and usually means two `StreamBuilder` widgets point at the same stream instance; fix it with `.asBroadcastStream()` or by hoisting the subscription up and fanning out from a controller. A broadcast stream drops every event that arrives while nobody is listening, so a late subscriber sees no history unless you use a `BehaviorSubject` from `rxdart` or a `ValueNotifier`. Backpressure does exist: the `StreamSubscription` returned by `listen` has `pause()` and `resume()`, and an `await for` loop applies backpressure implicitly because it pauses the source while the loop body runs, whereas `listen` with an async callback does not and will happily interleave. `StreamBuilder` rebuilds on every event, so you must handle `snapshot.connectionState` and `snapshot.hasError` or a stream error surfaces as a red error screen in release.
Stream<int> countdown(int from) async* {
for (var i = from; i > 0; i--) {
await Future.delayed(Duration(seconds: 1));
yield i;
}
}
// Consume with await-for
await for (final tick in countdown(5)) {
print(tick);
}
// Or with listen (and remember to cancel)
final sub = countdown(5).listen(print);
await Future.delayed(Duration(seconds: 2));
await sub.cancel();
Q17What are isolates and when should you use them?
IntermediateConcurrency
Answer
An isolate is Dart's unit of parallel execution, like a thread, but with no shared memory. Each isolate has its own heap and event loop; they communicate only via message passing (SendPort/ReceivePort). The main UI runs in the root isolate; if you do heavy CPU work there (JSON parsing of a 5MB response, image filtering, crypto), the UI freezes.
Spawn a worker isolate for those. Dart 2.19+ added `Isolate.run(() => heavyWork())` as the simple ergonomic API, it spawns an isolate, runs your function, returns the result, and tears the isolate down. Flutter wraps this further with `compute(fn, arg)` from `package:flutter/foundation.dart`.
Use isolates ONLY for CPU-bound work, they have setup cost and inter-isolate messaging requires JSON-serializable types (no closures, no Dart Future). The costs a senior interviewer probes: messages are deep-copied on send, so shipping a 20MB list into a worker and getting a 20MB result back costs two full copies and can wipe out the benefit. `TransferableTypedData.fromList` moves a byte buffer with no copy, and `Isolate.exit(port, result)` hands a result over without copying because the sending isolate is terminating anyway, which is what `Isolate.run` uses internally. Since Dart 2.15 isolates in the same isolate group share code and can pass immutable values, strings and numbers by reference, which is why `Isolate.run` is fast enough for per-request use. The Flutter-specific trap is plugins: a spawned isolate cannot use platform channels until you capture a `RootIsolateToken` on the main isolate, pass it in the spawn message, and call `BackgroundIsolateBinaryMessenger.ensureInitialized(token)` inside the worker. `dart:ui` objects, `BuildContext`, open sockets and anything holding a native pointer cannot cross the boundary at all.
import 'dart:isolate';
import 'dart:convert';
// Parse 10MB of JSON without freezing the UI
Future<List<User>> parseUsers(String json) async {
return Isolate.run(() {
final list = jsonDecode(json) as List;
return list.map((m) => User.fromJson(m)).toList();
});
}
// Flutter equivalent:
// import 'package:flutter/foundation.dart';
// final users = await compute(_parseInIsolate, jsonString);
Key Points
- No shared memory, message passing only
- Use for CPU-bound work (parsing, crypto, image processing)
- Setup cost ~1-10ms per spawn, don't use for sub-millisecond work
- `Isolate.run` (2.19+) is the modern API; older code uses `Isolate.spawn`
Q18What are mixins in Dart and how are they different from inheritance?
IntermediateOOP
Answer
A mixin lets you reuse code across class hierarchies without inheritance. Declare with `mixin Foo { ... }` and apply with `class Bar with Foo`. A class can `with` multiple mixins, which solves the multiple-inheritance problem without the diamond ambiguity (Dart linearises mixins).
Mixins can require certain superclasses with `mixin Logger on Widget { ... }`, `Logger` can only be applied to subclasses of `Widget`. This is exactly how Flutter's `SingleTickerProviderStateMixin` works: it's a mixin you add to a State class to provide a Ticker for animations, and it requires the host be a State. Compared to inheritance, mixins are composable (multiple per class) and don't form an 'is-a' relationship, they're 'has-the-behavior-of'.
The details that come up: mixin application is linear, so in `class A extends B with M1, M2` the resolution order is B, then M1, then M2, meaning M2 wins on a conflicting member and `super` inside M2 refers to M1 rather than to B. That chaining is exactly how Flutter composes observer mixins. Before Dart 3, any class without a generative constructor could be used after `with`; Dart 3 requires the explicit `mixin` keyword unless the declaration is marked `mixin class`, so upgrading an older package often surfaces an error saying the class can't be used as a mixin.
Mixins cannot declare constructors, so they cannot take configuration; the idiom is to declare an abstract getter that the host class must implement. The trap people hit in production is state: fields declared in a mixin exist on every host instance, so a cache inside a mixin is per-object, not shared, and a mixin holding a `StreamSubscription` still needs the host to cancel it in `dispose`.
mixin Logger {
void log(String msg) => print('[$runtimeType] $msg');
}
mixin Cached<T> on Repository {
final _cache = <String, T>{};
T? cached(String key) => _cache[key];
}
class UserRepo extends Repository with Logger, Cached<User> {
// gets log() from Logger AND cached() from Cached
}
// Real Flutter example:
class _MyState extends State<MyPage> with SingleTickerProviderStateMixin {
late final AnimationController _ctrl = AnimationController(vsync: this, ...);
}
Q19What are extension methods in Dart?
IntermediateOOP
Answer
Extension methods (Dart 2.7+) let you add methods to existing types without subclassing or modifying them. Declare with `extension Name on Type { ... }`. The methods are looked up statically, there's no virtual dispatch, but at the call site they look exactly like instance methods.
This is great for adding helpers to types you don't own (`String`, `int`, third-party classes). The downside is namespace pollution: every extension you import adds methods to every value of that type in scope. Mitigate by using `show`/`hide` on imports.
In Flutter, extensions on `BuildContext` (e.g. `context.theme`, `context.l10n`) are an extremely common pattern in modern codebases. The resolution rules are what interviewers check. Extension members are resolved against the static type, so an extension on `String` never fires for a variable typed `dynamic`, because calls on `dynamic` skip extension lookup entirely and go straight to runtime dispatch, then fail with `NoSuchMethodError`.
A real instance member always wins over an extension member of the same name, so you cannot use an extension to override behaviour. If two imported extensions define the same member for the same type the analyzer reports that the member is defined in multiple extensions, and you fix it either with `hide` on one import or by applying the extension explicitly as `StringX('abc').capitalize()`. Extensions cannot add fields, so there is no per-instance state, and they cannot be applied to a type parameter unless the bound provides the member. Dart 3.3 added extension types, which are a different feature entirely: a zero-cost wrapper that creates a genuinely distinct static type over an existing representation, which is what you want when the goal is to stop a `UserId` being passed where an `OrgId` belongs.
extension StringX on String {
bool get isEmail => RegExp(r'^[\w.+-]+@[\w-]+\.[a-z]{2,}\$').hasMatch(this);
String capitalize() => isEmpty ? this : this[0].toUpperCase() + substring(1);
}
print('asha@example.com'.isEmail); // true
print('hello'.capitalize()); // Hello
// Common Flutter idiom
extension ContextX on BuildContext {
ThemeData get theme => Theme.of(this);
ColorScheme get colors => Theme.of(this).colorScheme;
TextTheme get text => Theme.of(this).textTheme;
}
Q20Explain the `late` keyword and its gotchas.
IntermediateNull Safety
Answer
`late` declares a non-nullable variable that you promise to initialize before first use. The compiler skips the usual 'must be initialized in constructor' check; instead, a runtime check fires on first read and throws `LateInitializationError` if you didn't initialize it. Use cases: fields that depend on `this` (which isn't available in initializer lists), lazy expensive computations, and Flutter `StatefulWidget` fields that need `widget.foo` in `initState`.
The big gotcha: `late final` with an initializer is computed lazily on first access, not at declaration time, which can cause surprising ordering bugs. Another: `late` variables can be assigned multiple times unless you also write `final`. In production code, prefer constructor injection over `late` wherever feasible, `late` defers a compile-time guarantee to runtime, which is the kind of trade you should make consciously.
Two more behaviours to have ready. `late final` without an initializer can be assigned exactly once at runtime, and a second assignment throws a `LateInitializationError` saying the field has already been initialized, which makes it a useful one-shot dependency slot for something injected after construction. `late` on a top-level or static variable makes it lazily initialised on first read rather than at program start, which is the standard way to defer an expensive singleton without a `Completer`. The failure you will actually debug in production is a `LateInitializationError` on a `State` field where `initState` threw before reaching the assignment, so the real bug is upstream and the late error is just the symptom; the message names the field, so read it literally instead of guessing. Note also that `late` disables the compiler's definite-assignment analysis for that variable, so the analyzer stops warning about paths where you forgot to assign it.
class Repo {
late final db = _openDb(); // computed lazily on first read
late final ApiClient api; // must be assigned before first read
Repo() {
api = ApiClient(baseUrl: env.apiUrl);
}
}
class _PageState extends State<Page> {
late final tabs = widget.tabs; // widget.tabs unavailable in initializer
@override
void initState() {
super.initState();
// tabs is initialized lazily here
}
}
Q21What are records in Dart 3 and when should you use them?
IntermediateDart 3
Answer
Records (Dart 3.0, 2023) are a lightweight, immutable, anonymous aggregate of values with structural typing. Syntax is `(int, String)` for positional fields or `({int x, int y})` for named. The big use cases: returning multiple values from a function without defining a class, destructuring in pattern matching, and as map keys (they have built-in structural equality).
Records aren't a replacement for proper domain models, they have no name, no methods, no inheritance. Use them for ephemeral tuples (coordinate pairs, return value bundles) and use classes for anything that lives in your domain layer or appears in more than two or three places. The semantics interviewers check: record types are structural, so `(int, String)` declared in two different files is the same type, and equality plus `hashCode` compare fields positionally and by name with no code to write.
Positional fields are accessed as `$1`, `$2` and so on, one-based, which is easy to get wrong. A named field is not interchangeable with a positional one, so `(lat: 1.0, lng: 2.0)` will not assign to `(double, double)`. Records are immutable, cannot be subtyped, cannot declare methods, and cannot carry behaviour, so they do not replace `freezed` unions or a domain class with invariants.
They also do not serialise: there is no `toJson`, so a record crossing an HTTP or isolate boundary needs an explicit conversion. And because the field names are part of the type, renaming `(count: int)` to `(total: int)` is a breaking change with no deprecation path, which is the main argument for keeping records out of a public API surface.
// Return multiple values
(int min, int max) range(List<int> nums) {
return (nums.reduce((a, b) => a < b ? a : b), nums.reduce((a, b) => a > b ? a : b));
}
final (lo, hi) = range([4, 1, 9, 2, 7]); // destructure
print('$lo .. $hi'); // 1 .. 9
// Named record fields
({double lat, double lng}) location = (lat: 12.97, lng: 77.59);
print(location.lat);
// Records have value equality for free
print((1, 2) == (1, 2)); // true
Q22Explain pattern matching and switch expressions in Dart 3.
IntermediateDart 3
Answer
Dart 3 added full pattern matching: in switch statements, switch expressions, `if-case`, and variable declarations. Patterns can be literals, types, records, lists, objects, and they can destructure. Switch expressions return a value, making them composable in a way that the old statement-based switch wasn't.
Combined with sealed classes, you get exhaustive matching: the compiler ensures every case is handled, no `default` needed. This replaces a lot of `if-else` chains and visitor patterns. The two big quality-of-life wins are (1) destructuring records and objects inline, and (2) compiler-checked exhaustiveness over sealed hierarchies.
The rules interviewers test: exhaustiveness only applies over `sealed` types, enums and `bool`; a switch expression over an open class hierarchy still needs a `_` wildcard case or the analyzer reports that the type is not exhaustively matched. Cases in a switch expression are separated by commas, produce a value, and have no `break`, whereas a switch statement still needs `break` on non-empty cases. A `when` guard runs after the pattern matches and cannot promote types further, and a failed guard falls through to the next case rather than exiting the switch.
The map pattern is the one that changes day-to-day code: `if (json case {'id': final int id, 'name': final String name})` validates the shape and destructures in a single expression, which is genuinely safer than a chain of `as` casts because a missing or wrong-typed key just fails the match. Two caveats: object patterns invoke getters, so a pattern match can run arbitrary code, and a logical-or pattern must bind the same set of variables in every branch.
sealed class Result<T> {}
class Ok<T> extends Result<T> { final T value; Ok(this.value); }
class Err<T> extends Result<T> { final String message; Err(this.message); }
// Switch expression with exhaustive match, no default needed
String describe(Result<int> r) => switch (r) {
Ok(value: 0) => 'zero',
Ok(value: final n) when n > 0 => 'positive: $n',
Ok(value: final n) => 'negative: $n',
Err(message: final msg) => 'error: $msg',
};
// List patterns
final first = switch ([1, 2, 3]) {
[] => 'empty',
[final x] => 'single: $x',
[final x, ..., final y] => 'first $x last $y',
};
Q23What are sealed classes and class modifiers in Dart 3?
IntermediateDart 3
Answer
Dart 3 introduced a family of class modifiers: `sealed`, `base`, `final`, `interface`, and combinations. `sealed` means all direct subclasses must live in the same library, the compiler can then exhaustively check switch statements over the hierarchy, the killer feature for state machines and ADTs. `final` prevents subclassing outside the library (good for stable APIs). `base` requires subclasses to be `base`, `final`, or `sealed`, preventing implementation by random clients. `interface` means the class can only be implemented, not extended, useful for contract-only declarations. These modifiers let library authors choose how clients can extend their types, fixing a long-standing complaint that Dart's old 'every class is implicitly an interface' was too permissive. For app code, `sealed` is the one you'll use daily; the rest mostly matter for library design.
The practical constraints: `sealed` implies `abstract`, so you cannot instantiate the parent, and every subtype must live in the same library, which in practice means the same file or a `part` of it. Adding a new subtype is deliberately a breaking change, because every exhaustive switch across the codebase turns into a compile error until you handle the new case, and that is the whole point of choosing `sealed` for a state machine. `base` and `final` are transitive obligations: a subtype of a `base` class must itself be `base`, `final` or `sealed`, so marking a widely extended class `final` in a published package breaks downstream users and requires a major version bump. Not all combinations are legal, `sealed` cannot combine with `final` or `interface`, and a `mixin class` cannot be `abstract`. One more point that surprises people: pre-Dart-3 code relied on implicit interfaces, so any class could be `implement`ed by anyone, and adding these modifiers to an existing library is itself a breaking change for clients doing exactly that.
// State machine modelled as a sealed hierarchy
sealed class LoadState {}
class Idle extends LoadState {}
class Loading extends LoadState {}
class Loaded extends LoadState { final List<Item> items; Loaded(this.items); }
class Failed extends LoadState { final String error; Failed(this.error); }
Widget build(BuildContext context, LoadState state) => switch (state) {
Idle() => const SizedBox.shrink(),
Loading() => const CircularProgressIndicator(),
Loaded(items: final list) => ItemList(list),
Failed(error: final err) => ErrorView(err),
}; // exhaustive, compiler errors if you add a new state and forget a case
Q24How does state management work in Flutter, and what are the trade-offs of Provider, Riverpod, and Bloc?
IntermediateFlutter
Answer
Flutter's built-in state management is `setState` inside StatefulWidgets, fine for local UI state, painful for anything shared across screens. The three popular third-party options each take a different angle. **Provider** (the original) wraps `InheritedWidget` to expose values down the tree; simple, official-recommended for years, but lookup by type only and limited compile-time safety. **Riverpod** (by the same author) fixes Provider's pain points: providers live outside the widget tree, lookups are compile-time-typed, async state is first class via `AsyncValue<T>`. It's the default choice in new Flutter projects in 2026. **Bloc/Cubit** maps states to events via a Stream, extremely structured, great for complex flows, but more boilerplate per feature.
In India: Razorpay's mobile teams tend to use Bloc for predictability; consumer-app startups (Swiggy, NyKaa) tend to use Riverpod or `flutter_hooks` + Riverpod for velocity. Expect follow-ups on the mechanics rather than the marketing. Riverpod's `riverpod_generator` and the `@riverpod` annotation replace hand-written provider declarations and give you inferred types plus automatic disposal; the distinction that causes real bugs is `ref.watch` (subscribes and rebuilds) versus `ref.read` (one-shot, must never be called during `build`), and Provider's `context.watch` versus `context.read` is the exact same trap. `AsyncValue<T>` is the piece worth naming: it models loading, data and error as one sealed value so `switch` or `.when` forces you to handle all three instead of shipping a screen with no error state.
On the Bloc side, calling `emit` after `close()` throws a `StateError`, `BlocProvider` builds lazily unless you pass `lazy: false`, and `emit` with an identical state object is a no-op because Bloc compares with `==`, which is why Bloc states are usually `Equatable` or `freezed`. The strongest answer ends on testability: Riverpod with a `ProviderContainer` plus `overrideWith`, Bloc with `blocTest`, `setState` with a widget test and nothing else.
// Riverpod with code generation
@riverpod
Future<List<Order>> orders(Ref ref) async {
final api = ref.watch(apiClientProvider);
return api.fetchOrders();
}
class OrdersView extends ConsumerWidget {
const OrdersView({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final orders = ref.watch(ordersProvider);
return orders.when(
data: (list) => OrderList(list),
loading: () => const CircularProgressIndicator(),
error: (e, st) => ErrorView('$e'),
);
}
}
// Test: swap the API without touching the widget tree
final container = ProviderContainer(
overrides: [apiClientProvider.overrideWithValue(FakeApi())],
);
addTearDown(container.dispose);
Key Points
- Provider: simple, widget-tree based, type-only lookup
- Riverpod: compile-time safe, async-first, recommended default in 2026
- Bloc/Cubit: Stream-based, verbose but very structured
- `setState` is still correct for purely local UI state
Q25Explain the Flutter widget lifecycle and where Dart code runs.
IntermediateFlutter
Answer
A StatefulWidget has two parts: the widget (immutable config) and its State (mutable, persistent across rebuilds). Key lifecycle methods: `initState` runs once when the State is inserted into the tree, set up controllers, subscribe to streams, kick off initial fetches; `didChangeDependencies` runs after `initState` and again whenever an `InheritedWidget` you depend on changes; `build` runs every time the widget needs to be rendered (potentially 60 times a second during an animation), KEEP IT PURE, no side effects; `didUpdateWidget` runs when the parent rebuilds with a new instance of the same widget, use to react to changed parent props; `dispose` runs when the State is removed, cancel subscriptions, dispose controllers, close streams. The two biggest mistakes: doing async work in `build` (causes infinite rebuild loops) and forgetting to cancel/dispose in `dispose` (causes memory leaks and 'setState called on disposed' errors).
Two more hooks and one rule complete the picture. `deactivate` runs when the State is removed from the tree but might be reinserted elsewhere in the same frame, which is what happens on a `GlobalKey` move, so cleanup belongs in `dispose`, not there. `didChangeDependencies` is where `MediaQuery.of(context)` or `Theme.of(context)` work belongs, because `InheritedWidget` lookups are illegal in `initState` and throw about dependOnInheritedWidgetOfExactType being called before initState completes. The rule that catches everyone: after any `await`, check `mounted` before calling `setState` or touching `context`, and never call `setState` from `dispose` or you get an exception saying setState was called after dispose. Also remember `build` can be called for reasons that have nothing to do with your state, such as a parent rebuild, a media query change or a route animation, so any counter you increment inside `build` will lie. `StatelessWidget` has no lifecycle at all, only `build`, which is why converting a widget to stateless is the cheapest fix for a lifecycle bug.
class _CounterState extends State<Counter> {
late final StreamSubscription _sub;
@override
void initState() {
super.initState();
_sub = analyticsStream.listen(_onEvent); // one-time setup
}
@override
void didUpdateWidget(covariant Counter old) {
super.didUpdateWidget(old);
if (old.userId != widget.userId) _refetch(); // react to prop change
}
@override
void dispose() {
_sub.cancel(); // ALWAYS clean up
super.dispose();
}
@override
Widget build(BuildContext context) => Text('Count: ${widget.value}');
}
Q26What is `FutureOr<T>` and when do you encounter it?
IntermediateAsync
Answer
`FutureOr<T>` is a special type that means 'either a `T` or a `Future<T>`'. It exists for API ergonomics: a function parameter typed `FutureOr<int>` lets the caller pass either `42` or `Future.value(42)`, and the implementation decides whether to `await` it. You can't write `FutureOr` as a return type in your own code as freely as you might want, the convention is to use it only in parameters and abstract method signatures.
You see it most in stream APIs (`Stream.fromFuture` accepts `FutureOr`), in `Future.then(onValue)` (the callback returns `FutureOr<R>`), and in `Completer.complete`. Internally Dart handles the type-test efficiently; you rarely need to think about it unless you're authoring a library. The sharp edges: `FutureOr<T>` is a union, so you cannot call `T` members on it without narrowing first, and a runtime test is close to useless because `x is FutureOr<int>` is true for both an `int` and a `Future<int>`. `FutureOr<void>` and `FutureOr<Null>` are pathological, everything satisfies them, so the analyzer stops helping you entirely; the `avoid_void_async` and `await_only_futures` lints exist partly because of this.
Using it as a return type is legal but discouraged, because callers then have to `await` defensively and you have moved the ambiguity onto them rather than absorbing it. The everyday sightings are `Stream.asyncMap`, `Future.then`, `Completer.complete`, `StreamTransformer.fromHandlers` and retry or cache helpers where the value might already be in memory. Two useful facts: `await` on a non-Future value is allowed and simply costs one microtask turn, and `Future.value(x)` where `x` is already a `Future<T>` returns that same future rather than nesting it, because `Future<Future<T>>` cannot be constructed in Dart.
Future<T> retry<T>(
FutureOr<T> Function() task, {
int attempts = 3,
}) async {
for (var i = 0; i < attempts; i++) {
try {
return await task(); // works whether task returns T or Future<T>
} catch (_) {
if (i == attempts - 1) rethrow;
await Future.delayed(Duration(milliseconds: 200 * (1 << i)));
}
}
throw StateError('unreachable');
}
// Caller can pass sync OR async, both work
await retry(() => 42);
await retry(() async => await http.get(uri));
Q27How do you handle JSON serialization in Dart?
IntermediateSerialization
Answer
Dart's `dart:convert` gives you `jsonDecode` (returns `dynamic`, usually `Map<String, dynamic>` or `List<dynamic>`) and `jsonEncode`. The problem: hand-writing `fromJson`/`toJson` for every model is tedious and error-prone, especially around nullable fields and nested models. Three production options: (1) **Manual**, write `factory User.fromJson(Map json)` by hand.
Fine for under ~10 models. (2) **`json_serializable`**, code generation via `build_runner`. You annotate the class, run `dart run build_runner build`, and get generated `_$UserFromJson`/`_$UserToJson`. The 2026 default for most Flutter apps. (3) **`freezed`**, wraps `json_serializable` plus generates `copyWith`, `==`, `hashCode`, and sealed unions.
Heavier but pays for itself if your app has many models. Always validate at the boundary, never trust that the API returned the shape you expected. The failure modes worth naming: `jsonDecode` returns `dynamic`, so a field that the backend sometimes sends as `42` and sometimes as `"42"` does not fail at decode time, it fails much later with a message about String not being a subtype of int, somewhere deep in a widget; a `@JsonKey(fromJson: _asInt)` converter at the boundary is the fix.
Nested model lists need `@JsonSerializable(explicitToJson: true)` or `toJson` emits `Instance of 'Address'` instead of a map, which then serialises as a useless string in your request body. Generic classes need `genericArgumentFactories: true` and an extra function argument. `null` versus absent is a real distinction: `@JsonKey(includeIfNull: false)` controls whether a null field is omitted from the payload, which matters for PATCH endpoints. Large payloads should not be parsed on the UI thread, wrap the decode in `Isolate.run` or Flutter's `compute`; for very large responses, `utf8.decoder.bind(response.stream).transform(json.decoder)` streams the parse instead of materialising a multi-megabyte string first.
// With json_serializable
import 'package:json_annotation/json_annotation.dart';
part 'user.g.dart';
@JsonSerializable()
class User {
final int id;
final String email;
@JsonKey(name: 'first_name') final String firstName;
final List<Address>? addresses;
User({required this.id, required this.email, required this.firstName, this.addresses});
factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
Map<String, dynamic> toJson() => _$UserToJson(this);
}
// Then: dart run build_runner build --delete-conflicting-outputs
Q28How do generics work in Dart, and what do `covariant` and bounded type parameters change?
IntermediateType System
Answer
Dart generics are reified, which is the first thing to say: unlike Java, the type argument survives to runtime, so `list is List<String>` genuinely works and inserting the wrong element throws a `TypeError` at the insertion point rather than silently poisoning the collection. You add a bound with `extends`, as in `T largest<T extends Comparable<T>>(Iterable<T> xs)`, and inside that function you can call `compareTo`. Without a bound the type parameter behaves as `Object?`, so you can only call `toString`, `hashCode` and `==`.
The part interviewers dig into is variance. Dart generics are covariant by default, so `List<Dog>` is a subtype of `List<Animal>`. That is convenient and unsound in the same way Java arrays are: you can pass a `List<Dog>` where a `List<Animal>` is expected, call `add(Cat())`, and get a runtime `TypeError` that the analyzer never warned you about.
The defensive habit is to type read-only parameters as `Iterable<T>` so there is no `add` to abuse. Method parameters would be contravariant in a sound system, so narrowing a parameter type in an override is normally rejected; the `covariant` keyword opts out for one parameter and says you accept the runtime check. Flutter uses it in `void didUpdateWidget(covariant MyWidget oldWidget)`. Two more facts: you cannot write `T()` because AOT Dart has no reflection, so factories are passed as `T Function()` arguments, and generic methods can live on non-generic classes.
// Bounded type parameter unlocks members on T
T largest<T extends Comparable<T>>(Iterable<T> xs) =>
xs.reduce((a, b) => a.compareTo(b) >= 0 ? a : b);
// Covariance hole: the analyzer allows it, the runtime rejects it
final dogs = <Dog>[Dog()];
final List<Animal> animals = dogs; // legal, List<Dog> is a List<Animal>
// animals.add(Cat()); // TypeError: Cat is not a subtype of Dog
// `covariant` narrows an override deliberately
class Animal {
void greet(Animal other) {}
}
class Dog extends Animal {
@override
void greet(covariant Dog other) {} // runtime-checked narrowing
}
// No `T()` in Dart: inject the factory instead
class Lazy<T> {
Lazy(this._create);
final T Function() _create;
T? _value;
T get value => _value ??= _create();
}
Key Points
- Type arguments are reified, so `is List<String>` works at runtime
- Generics are covariant by default, which is unsound for mutable collections
- `covariant` allows a narrowed override parameter with a runtime check
- `T()` is illegal; pass a `T Function()` factory instead
Q29When do you reach for `Completer` or `StreamController`, and how do you avoid leaking them?
IntermediateAsync
Answer
`Completer<T>` is how you adapt a callback-based API into a Future. You create one, hand `completer.future` to the caller, and call `completer.complete(value)` or `completer.completeError(e, st)` from the callback. Two rules matter: complete exactly once, because a second call throws `Bad state: Future already completed`, so guard with `if (!completer.isCompleted)`; and always complete on the failure path too, because a Completer that is never completed leaves the awaiting code suspended forever with no exception, no log line and no crash report, which is one of the hardest bugs to find.
Adding `.timeout(const Duration(seconds: 10))` on the returned future turns that silent hang into a `TimeoutException`. `StreamController<T>` is the same idea for multiple events. The default constructor gives a single-subscription controller that buffers events until someone listens; `StreamController.broadcast()` allows many listeners but drops events when nobody is subscribed. You wire resource lifecycle through `onListen`, `onCancel`, `onPause` and `onResume`, which is how you start a socket or a location subscription only while something is actually watching.
Leaks come from three places: never calling `controller.close()`, never cancelling the `StreamSubscription` from `listen`, and adding after close, which throws `Bad state: Cannot add new events after calling close`. In a Flutter `State`, close and cancel in `dispose`. The design rule: if the producer is a loop you own, prefer an `async*` generator, because it handles pause and cancel for you; reach for a controller only when events originate outside your call stack.
// Callback API to Future, with the two guards that matter
Future<Position> currentPosition() {
final completer = Completer<Position>();
locationPlugin.getPosition(
onSuccess: (p) {
if (!completer.isCompleted) completer.complete(p);
},
onError: (e, st) {
if (!completer.isCompleted) completer.completeError(e, st);
},
);
return completer.future.timeout(const Duration(seconds: 10));
}
// Controller that owns a resource only while someone listens
class TickService {
Timer? _timer;
late final StreamController<int> _controller = StreamController<int>.broadcast(
onListen: () => _timer = Timer.periodic(
const Duration(seconds: 1), (t) => _controller.add(t.tick)),
onCancel: () => _timer?.cancel(),
);
Stream<int> get ticks => _controller.stream;
Future<void> dispose() => _controller.close();
}
Q30How do you test Dart and Flutter code: unit, widget, golden, and what does `flutter test --coverage` produce?
IntermediateTesting
Answer
Pure Dart uses `package:test`: `group`, `test`, `expect` with matchers such as `equals`, `throwsA(isA<FormatException>())` and `completion`, plus `setUp` and `tearDown`. Run it with `dart test`, or `flutter test` inside a Flutter package because the tests need the Flutter binding. Widget tests use `testWidgets` and a `WidgetTester`.
The loop is `await tester.pumpWidget(MyApp())`, then interact via `tester.tap(find.byKey(const Key('submit')))` or `tester.enterText`, then `await tester.pump()` for exactly one frame or `await tester.pumpAndSettle()` to run frames until nothing is scheduled. `pumpAndSettle` fails with a timeout if an indefinite animation is on screen, and a `CircularProgressIndicator` is the usual culprit, so pump a fixed duration instead. Time is fake inside widget tests, so a `Future.delayed` only advances when you pump, and `tester.runAsync` is the escape hatch when you genuinely need real async work such as decoding an image. Golden tests compare a rendered widget against a stored PNG with `expectLater(find.byType(Card), matchesGoldenFile('card.png'))` and are regenerated with `flutter test --update-goldens`; they are font and platform sensitive, so pin them to one CI image.
For mocking, `mocktail` needs no code generation and works cleanly with null safety, `fake_async` lets you advance virtual time in pure Dart tests, and `http` ships a `MockClient`. `flutter test --coverage` writes `coverage/lcov.info`, which you turn into a report with `genhtml` or upload to a coverage service. End-to-end tests live in `integration_test/` and run on a real device or emulator.
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
class MockRepo extends Mock implements OrderRepo {}
void main() {
late MockRepo repo;
setUp(() => repo = MockRepo());
test('maps a socket failure to a domain error', () async {
when(() => repo.fetch()).thenThrow(SocketException('offline'));
await expectLater(loadOrders(repo), throwsA(isA<OfflineFailure>()));
});
testWidgets('renders the empty state, then refetches on tap',
(WidgetTester tester) async {
when(() => repo.fetch()).thenAnswer((_) async => <Order>[]);
await tester.pumpWidget(OrdersScreen(repo: repo));
await tester.pump(); // let the future resolve
expect(find.text('No orders yet'), findsOneWidget);
await tester.tap(find.byKey(const Key('refresh')));
await tester.pumpAndSettle();
verify(() => repo.fetch()).called(2);
});
}
Key Points
- `pump()` runs one frame, `pumpAndSettle()` runs until nothing is scheduled
- Time is fake in widget tests; `tester.runAsync` opts back into real async
- `mocktail` avoids code generation, `fake_async` advances virtual time
- `flutter test --coverage` writes `coverage/lcov.info`
Q31What are extension types in Dart 3.3, and how do they differ from extension methods and `typedef`?
IntermediateDart 3
Answer
An extension type is a compile-time-only wrapper over an existing representation type. `extension type UserId(String value) {}` creates a distinct static type whose runtime representation is just the underlying `String`, so there is no allocation, no boxing and no indirection. It solves primitive obsession: a function declared as `void audit(UserId user, OrgId org)` cannot be called with the two arguments swapped, whereas two `String` parameters can and will be, eventually, in production. The difference from a `typedef` is safety: `typedef UserId = String` is a pure alias, fully interchangeable with `String`, and buys you nothing beyond documentation.
The difference from an extension method is scope: an extension adds members to every value of the underlying type that is in scope, while an extension type only exposes members on values you deliberately wrapped, and it can hide the underlying members entirely. There are two modes. Declared without `implements`, the type is opaque: a `UserId` is not assignable to `String` and `String` members are unavailable, which is what you want for identifiers.
Declared as `extension type Paise(int value) implements int`, it is transparent: assignable to `int` and inheriting its members, which suits units and measures where you want additive typing. The caveat that matters is that the wrapper does not exist at runtime, so `userId is String` is `true`, `runtimeType` reports `String`, and a `List<UserId>` is literally a `List<String>`. You cannot switch on extension types or rely on them for dynamic dispatch. They are static guarantees with zero runtime cost, and they also underpin the typed JavaScript bindings in `dart:js_interop`.
// Opaque: distinct type, underlying members hidden
extension type UserId(String value) {
bool get looksLikeObjectId => value.length == 24;
}
extension type OrgId(String value) {}
// Transparent: assignable to int and inherits its members
extension type Paise(int value) implements int {
String get formatted => (value / 100).toStringAsFixed(2);
}
void audit(UserId user, OrgId org) {}
final user = UserId('64f1c2a0e1b2c3d4e5f60718');
final org = OrgId('acme');
audit(user, org);
// audit(org, user); // compile error: OrgId is not UserId
// print(user.length); // compile error: String members are hidden
print(user.value.length); // 24
print(Paise(125000) + 1); // 125001, inherited from int
print(Paise(125000).formatted); // 1250.00
// Zero cost: no wrapper object exists at runtime
print(user is String); // true
Q32How would you architect a Flutter app for performance at scale, and what Dart-level optimisations matter most?
AdvancedPerformance
Answer
The biggest wins in order of impact: (1) **`const` everywhere it compiles**, `const` widgets are cached and skipped during rebuild. The `prefer_const_constructors` lint is non-negotiable on a real codebase. (2) **Keep `build` methods pure and cheap**, no I/O, no allocation of expensive objects, no synchronous JSON parsing. (3) **Use `Isolate.run` / `compute` for any CPU work over ~16ms** (one frame at 60fps), JSON over a few hundred KB, image processing, crypto, large list filtering. (4) **`ListView.builder` and `SliverList` over fixed lists**, they materialise widgets lazily as you scroll, instead of building 10k items up front. (5) **Profile with the Flutter DevTools timeline**, the 'jank' indicator tells you exactly which frames overran 16ms and what was in them. At the Dart level: avoid unnecessary closures in hot paths (they allocate), prefer `final` lists you reuse over fresh lists each frame, and watch out for `==` overrides that walk deep object graphs (use shallow value equality or use Dart records).
For network-heavy apps, cache aggressively, `dio` with a Hive or sqflite cache layer, keyed by request URL. NyKaa and Swiggy app teams have written publicly about exactly these patterns.
Key Points
- `const` constructors are free performance, enable the lint
- Keep `build()` pure and synchronous
- Offload CPU-bound work to isolates via `Isolate.run` / `compute`
- ListView.builder for any list over ~20 items
- DevTools timeline is the source of truth for jank
Q33Explain Dart FFI and when you would call into C/Rust code.
AdvancedInterop
Answer
`dart:ffi` (Foreign Function Interface) lets Dart call into native C ABI libraries, `.so` on Android/Linux, `.dylib` on macOS/iOS, `.dll` on Windows. The mechanics: open a `DynamicLibrary`, look up a symbol, declare its C signature as a Dart typedef, then call it. You hand-marshal arguments (`Pointer<Utf8>`, `Pointer<Int32>`, etc.) using `package:ffi`.
For Rust specifically, `flutter_rust_bridge` generates the binding glue from Rust function signatures, much easier than hand-rolling FFI declarations. When should you use it? (1) **Existing C/Rust libraries** you can't reasonably rewrite (libsodium for crypto, OpenCV for vision, a proprietary SDK from a hardware vendor). (2) **CPU-bound hot paths** where Dart's overhead matters, image codecs, audio DSP, cryptography. (3) **Memory-sensitive code** where you want direct buffer control. The cost: FFI calls are cheap (~tens of nanoseconds) but data marshalling can dominate, copying a 10MB buffer twice (Dart → C → Dart) wastes more time than the native code saves.
Profile carefully. For most Flutter apps, you'll never need FFI; for apps integrating with native SDKs or doing heavy compute, it's table stakes. Three practical notes complete the answer.
Memory allocated with `calloc` or `malloc` from `package:ffi` is invisible to the Dart heap profiler, so an FFI leak shows up as growing RSS with a flat Dart heap; free it with `calloc.free(pointer)` in a `finally`, or attach a `NativeFinalizer` so cleanup runs when the owning Dart object is collected. Calling back from C into Dart needs `NativeCallable.isolateLocal` or `NativeCallable.listener` (Dart 3.1 and later) rather than the older `Pointer.fromFunction`, because a raw function pointer can only be invoked on the isolate that created it and a native thread calling it directly will crash. Finally, an FFI call blocks the calling isolate for its whole duration, so a 200ms native decode on the UI isolate janks exactly as badly as 200ms of Dart would; run it inside `Isolate.run` or use the async `NativeCallable.listener` pattern.
import 'dart:ffi';
import 'package:ffi/ffi.dart';
// C signature: int32_t add(int32_t a, int32_t b);
typedef _AddC = Int32 Function(Int32 a, Int32 b);
typedef _AddDart = int Function(int a, int b);
final _lib = DynamicLibrary.open('libnative_math.so');
final _add = _lib.lookupFunction<_AddC, _AddDart>('add');
int addNative(int a, int b) => _add(a, b);
Q34How does code generation with build_runner work, and when should you reach for it?
AdvancedTooling
Answer
`build_runner` is Dart's official code-generation pipeline. Packages register `Builder`s that analyse your source code (typically annotations) and produce `.g.dart` or `.freezed.dart` files alongside. You run `dart run build_runner build` (or `watch` during development) and the generated files appear next to your originals.
The big consumers: `json_serializable` for JSON, `freezed` for sealed unions + copyWith + equality, `riverpod_generator` for typed Riverpod providers, `mockito` for test mocks, `drift` for type-safe SQL, and `auto_route` / `go_router_builder` for routes. The win is huge, you write a small declarative model and get hundreds of lines of correct, mechanical code. The costs to plan for: (1) generated files belong in `.gitignore` only if you also enforce `build` in CI, most teams check generated files in to keep CI fast and avoid 'works on my machine' breakage. (2) build_runner is slow on large repos, incremental builds help but cold builds can take a minute. (3) Generated code is opaque to grep, IDE go-to-definition is essential.
When to NOT reach for it: for a single one-off model where hand-writing fromJson is faster than configuring the generator. Otherwise, anything you'll repeat, JSON models, equality, copyWith, providers, route declarations, code generation pays for itself within a sprint.
Key Points
- Annotate, run `build_runner build`, get `.g.dart` files
- Use `build_runner watch` during development
- Check generated files into git unless your CI enforces builds
- Big consumers: json_serializable, freezed, riverpod_generator, drift, mockito
Q35How does Dart's tree-shaking work, and what code patterns defeat it?
AdvancedBuild
Answer
Tree-shaking is the AOT compiler's dead-code elimination pass, it walks the call graph from the entrypoint and drops anything unreachable. This is what keeps Flutter web bundles and AOT mobile binaries reasonably small. The compiler is good at it for typical Dart code, but several patterns force it to over-retain: (1) **Reflection via `dart:mirrors`**, disabled in Flutter precisely because it defeats tree-shaking; if any code could call any method dynamically, nothing is dead. (2) **String-based instantiation**, `getClass('UserService').newInstance()` style.
The compiler can't prove the string is unused. (3) **`@pragma('vm:entry-point')`** annotations, explicitly tell the compiler 'keep this even if it looks unused', because something at runtime (native plugin, FFI callback) will call it. Don't add this unnecessarily. (4) **Plugins with method-channel dispatch tables**, generated code typically wraps these with explicit entry-point annotations so they survive. To audit bundle size: build with `--analyze-size` (e.g. `flutter build apk --analyze-size`) which produces a treemap showing exactly which packages contribute what.
Common offenders are bringing in `intl` for one date format (consider `intl_translation` patterns) or pulling in a heavy package for a single utility function. For Flutter web specifically, the dart2js compiler does additional minification, and the new dart2wasm path produces even smaller, faster output in 2026.
// Kept on purpose: called from native code or an FFI callback, so the AOT
// compiler cannot see a Dart call site and would otherwise drop it.
@pragma('vm:entry-point')
void firebaseBackgroundHandler(RemoteMessage message) {
// ...
}
// Defeats elimination: every entry stays reachable because the compiler
// cannot prove which string keys are ever looked up.
final routes = <String, Widget Function()>{
'home': () => const HomePage(),
'settings': () => const SettingsPage(),
'debug': () => const DebugPanel(), // ships even in release
};
Widget open(String name) => routes[name]!();
// Audit what actually ships:
// flutter build apk --release --analyze-size --target-platform android-arm64
// flutter build ipa --release --analyze-size
// flutter build web --release (dart2js)
// flutter build web --wasm (dart2wasm)
Q36How would you design an offline-first Flutter app with conflict resolution?
AdvancedArchitecture
Answer
Offline-first means the UI reads from and writes to a local store first, and a separate sync layer reconciles with the server when connectivity returns. Architecture: (1) **Local store**, sqflite or drift (typed SQL) for relational data, Hive or Isar for key-value/object storage. Drift is the 2026 default for anything beyond simple key-value. (2) **Write-ahead log**, every mutation appends to a queue (`pending_ops` table) with `op_id`, `op_type`, `payload`, `created_at`.
The UI commits to the local store optimistically AND enqueues the op. (3) **Sync engine**, a background isolate or service drains the queue against the server. On 2xx, mark op acknowledged; on retry-able errors (5xx, network), backoff exponentially; on permanent errors (4xx validation), surface to the user. (4) **Conflict resolution strategy**, the hard part. Options: **last-write-wins** by server timestamp (simple, sometimes wrong); **CRDTs** like LWW-element-set or Yjs (correct but heavy); **operational transformation** (Google Docs-style, complex).
For most line-of-business apps, server-side resolution with per-field LWW plus a 'merge needed' UI prompt for collisions is a good 80/20 trade. (5) **Authoritative state**, when the server pushes a corrected value (via long-poll or websocket), upsert into the local store and let any open widgets rebuild via the reactive layer (Riverpod, Bloc). Battery: keep sync work batched and triggered by connectivity events; don't keep polling on cellular. Test rigorously with network throttling and airplane mode toggles, many subtle bugs only surface on flaky connections.
Key Points
- Local store first (drift / sqflite / Isar), UI reads/writes locally
- Append every mutation to a pending-ops queue
- Sync engine drains queue with exponential backoff
- Conflict resolution: LWW for most cases, CRDTs if you need correctness
- Test with airplane mode and slow-network throttling
Q37A Flutter release build drops frames while scrolling a product list. How do you diagnose it?
AdvancedDebugging
Answer
Reproduce in profile mode first: `flutter run --profile` on a real mid-range Android device. Debug builds run the JIT with assertions enabled and are several times slower, so any number you take there is noise, and this is the first thing an interviewer checks you know. Then split the two threads.
In DevTools, the Performance view shows a frame chart where each bar is broken into UI work and raster work, with the budget at 16.7ms for 60Hz or 8.3ms for 120Hz. A tall UI bar means your Dart build, layout or state work is too slow. A tall raster bar means the display list you produced is too expensive for the GPU.
The fixes are completely different, so guessing here wastes days. For UI-thread jank: turn on track-widget-builds to find subtrees rebuilding that should not, confirm the list uses `ListView.builder` with `itemExtent` or `prototypeItem` so the sliver does not measure every child, move JSON parsing and image decoding off the UI isolate with `Isolate.run`, and check that `build` is not allocating controllers or doing synchronous I/O. `const` subtrees and a `RepaintBoundary` around an expensive but static child both cut work directly. For raster-thread jank: run with `--trace-skia` and look for shader compilation spikes on first interaction, then hunt `saveLayer` sources such as `Opacity`, `BackdropFilter` and anti-aliased `ClipRRect`, plus oversized images.
Passing `cacheWidth` and `cacheHeight` to `Image.network` decodes at display size instead of the full source resolution, which is usually the single biggest win on a product grid. Finally, stop guessing in production: `SchedulerBinding.instance.addTimingsCallback` gives you real `FrameTiming` data per frame that you can sample into analytics.
// Measure real frames in production instead of guessing
void main() {
WidgetsFlutterBinding.ensureInitialized();
SchedulerBinding.instance.addTimingsCallback((List<FrameTiming> timings) {
for (final t in timings) {
final buildMs = t.buildDuration.inMicroseconds / 1000; // UI thread
final rasterMs = t.rasterDuration.inMicroseconds / 1000; // raster thread
if (buildMs + rasterMs > 16.7) {
analytics.log('slow_frame', {'build': buildMs, 'raster': rasterMs});
}
}
});
runApp(const MyApp());
}
// The three cheap wins on a heavy scrolling list
ListView.builder(
itemExtent: 96, // skips per-child measurement
itemCount: products.length,
itemBuilder: (context, i) => RepaintBoundary(
child: ProductTile(
key: ValueKey(products[i].id),
imageUrl: products[i].imageUrl,
cacheWidth: 320, // decode at display size, not 4000px
),
),
);
Key Points
- Profile mode on a real device, never debug mode
- Separate UI-thread jank from raster-thread jank before fixing anything
- `itemExtent` / `prototypeItem` remove per-child measurement cost
- `cacheWidth` / `cacheHeight` stop full-resolution image decodes
- `addTimingsCallback` gives you production frame data
Q38How do you find and fix a memory leak in a long-running Flutter app?
AdvancedDebugging
Answer
Start with the symptom and a reproduction. A leak looks like resident memory climbing across navigation cycles and never returning to baseline, ending in an Android low-memory kill that Crashlytics reports as a bare native crash with no Dart stack. The reproduction is always the same shape: push a screen, pop it, repeat twenty times, and watch the memory graph.
Tooling next. In DevTools open the Memory view, take a heap snapshot, run the loop, take a second snapshot and diff them to see which classes grew. Then select a surviving instance and read its retaining path, because that path names the object still holding the reference, and that object is the actual bug rather than the leaked widget.
Flutter 3.16 added `leak_tracker`, which you enable in tests through `LeakTesting.settings`; it reports objects disposed but never garbage collected and objects garbage collected without ever being disposed, so leaks fail CI instead of reaching users. The recurring Dart causes are short and worth reciting: a `StreamSubscription` from `listen` that is never cancelled, a `Timer.periodic` that keeps firing after the route is gone, an `AnimationController`, `TextEditingController`, `ScrollController` or `FocusNode` created in `initState` and never disposed, an `addListener` with no matching `removeListener`, a singleton or static cache holding a `BuildContext` or a whole `State`, and a closure captured by a long-lived object that transitively pins an entire widget subtree. Images are the other half of real-world memory pressure. `PaintingBinding.instance.imageCache` holds decoded bitmaps, and a grid of full-resolution photos exhausts it quickly, so set `cacheWidth`, use a disk-backed cache, and drop entries on `didHaveMemoryPressure`.
class _FeedState extends State<Feed> with SingleTickerProviderStateMixin {
final ScrollController _scroll = ScrollController();
late final AnimationController _anim = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 300),
);
StreamSubscription<Event>? _sub;
Timer? _poll;
@override
void initState() {
super.initState();
_sub = eventBus.stream.listen(_onEvent);
_poll = Timer.periodic(const Duration(seconds: 30), (_) => _refresh());
_scroll.addListener(_onScroll);
}
@override
void dispose() {
_sub?.cancel(); // the closure pins this State otherwise
_poll?.cancel();
_scroll.removeListener(_onScroll);
_scroll.dispose();
_anim.dispose();
super.dispose();
}
}
// Bound the decoded-image cache on image-heavy screens
PaintingBinding.instance.imageCache.maximumSizeBytes = 64 << 20; // 64MB
Q39What has changed in Dart since 3.0, and how does that affect an existing codebase?
AdvancedLanguage Evolution
Answer
Dart 3.0 in May 2023 was the breaking release: records, patterns, class modifiers, and the removal of unsound null safety, which means a dependency that never migrated no longer resolves at all rather than merely warning. Everything since has been additive, but each release changes what a reviewer expects to see. Dart 3.2 improved flow analysis so private final fields promote, meaning `if (_token != null)` now narrows `_token` itself and you can delete the `final t = _token;` copy that used to be required.
Dart 3.3 stabilised extension types and `dart:js_interop`, which is now the supported way to call JavaScript and replaces `package:js`, and it is also what unlocked the WebAssembly output path. Dart 3.4 made `dart2wasm` production-ready for Flutter web, which is the largest performance change on that platform in years. Dart 3.6 added digit separators, so `1_000_000` is a legal literal, and pub workspaces, which let a monorepo share one lockfile and one resolution across packages.
Dart 3.7 shipped the tall-style formatter, so the first `dart format` after upgrading reflows the entire repository and should land as its own commit before anything else, and it added wildcard variables where `_` binds nothing. Dart 3.8 added null-aware elements in collection literals, so `[?maybeHeader]` omits the entry when the value is null. The other thing worth knowing is what did not ship: Google stopped work on static metaprogramming macros in January 2025, so `build_runner` code generation is the long-term answer rather than a stopgap. The upgrade routine in practice: raise the SDK lower bound in `pubspec.yaml`, run `dart pub upgrade --major-versions`, apply `dart fix --apply` for the mechanical migrations, then `dart analyze --fatal-infos` before you edit anything by hand.
// Dart 3.2: private final fields promote, no local copy needed
class Session {
Session(this._token);
final String? _token;
String get header => _token != null ? 'Bearer $_token' : '';
}
// Dart 3.3: zero-cost extension type
extension type Paise(int value) implements int {}
// Dart 3.6: digit separators
const monthlyRequestCap = 1_000_000;
// Dart 3.7: wildcards bind nothing
final (_, second) = ('ignored', 42);
stream.listen((_) => _counter++);
// Dart 3.8: null-aware elements
String? authHeader;
final headers = <String>[
'Accept: application/json',
?authHeader, // omitted entirely when null
];
Key Points
- Dart 3.0 removed unsound null safety, so unmigrated packages simply fail to resolve
- Dart 3.3: extension types and `dart:js_interop` replace `package:js`
- Dart 3.7's tall formatter reflows a whole repo, land it as its own commit
- Macros were cancelled in January 2025, so `build_runner` is the durable answer
Q40How do you move large data between isolates without copying, and how do you keep a worker isolate alive across many jobs?
AdvancedConcurrency
Answer
`Isolate.run` is the right default, but it spawns and tears down an isolate per call, so for hundreds of jobs the spawn cost plus two message copies dominates. The pattern for a persistent worker is a handshake followed by multiplexing: spawn once with `Isolate.spawn(entry, mainSendPort)`, have the worker create its own `ReceivePort` and send that port back as its very first message, then tag every request with an integer id, keep a `Map<int, Completer>` of in-flight jobs on the caller side, and complete the matching Completer when a reply with that id arrives. Copying is the real cost to reason about.
Messages are deep-copied, so a 50MB `Uint8List` is copied on the way in and again on the way out. Two escape hatches exist. `TransferableTypedData.fromList` moves ownership of a byte buffer with no copy, and the sender must not touch it afterwards. `Isolate.exit(replyPort, result)` hands the result over without copying because the sending isolate is terminating anyway, which is exactly the trick `Isolate.run` uses internally. Since Dart 2.15, isolates in one isolate group share code and pass immutable values, strings and numbers by reference rather than copying them.
What cannot cross a port: closures that capture mutable state, open sockets, `dart:ui` objects, `BuildContext`, and anything holding a raw native pointer. `SendPort` and `Capability` can. Two operational details finish the answer: pass `errorsAreFatal: true` or wire `onError` and `onExit` ports, otherwise a crashed worker just stops replying and every pending Completer hangs silently, and call `BackgroundIsolateBinaryMessenger.ensureInitialized(rootIsolateToken)` inside the worker if it needs any Flutter plugin.
import 'dart:isolate';
import 'package:async/async.dart';
class Worker {
Worker._();
late final SendPort _tx;
final _pending = <int, Completer<Object?>>{};
int _nextId = 0;
static Future<Worker> spawn() async {
final w = Worker._();
final rx = ReceivePort();
await Isolate.spawn(_entry, rx.sendPort, errorsAreFatal: true);
final queue = StreamQueue<dynamic>(rx);
w._tx = await queue.next as SendPort; // handshake
queue.rest.listen((msg) {
final (int id, Object? result) = msg as (int, Object?);
w._pending.remove(id)?.complete(result);
});
return w;
}
Future<Object?> run(Object? job) {
final id = _nextId++;
final completer = Completer<Object?>();
_pending[id] = completer;
_tx.send((id, job));
return completer.future;
}
static void _entry(SendPort tx) {
final rx = ReceivePort();
tx.send(rx.sendPort);
rx.listen((msg) {
final (int id, Object? job) = msg as (int, Object?);
tx.send((id, expensiveParse(job as String)));
});
}
}
Key Points
- Persistent worker: handshake a SendPort back, then multiplex by request id
- `TransferableTypedData` moves byte buffers with zero copy
- `Isolate.exit(port, result)` returns a result without copying it
- `errorsAreFatal: true` or an `onError` port, otherwise crashes hang callers
- Plugins need `BackgroundIsolateBinaryMessenger.ensureInitialized` in the worker
Frequently Asked Questions
Is Dart only for Flutter in 2026?
Almost. There is a small server-side Dart ecosystem (shelf, dart_frog) and Google uses Dart internally for some backend services, but in the open job market, ~95% of Dart roles are Flutter mobile or Flutter web. If you're investing in Dart, plan to learn it in tandem with Flutter, the Dart-only roles are rare. On whether it is worth picking up in 2026: the ramp-up is short if you already know Java, Kotlin, TypeScript or Swift, usually a week to be productive and a month to be good, and one Flutter team can cover iOS, Android and web, which is why small and mid-size Indian product companies keep hiring for it. The risk to price in honestly is that Dart is a single-vendor language whose demand is tied to Google continuing to invest in Flutter, so most engineers pair it with one native platform (Kotlin and Android, or Swift and iOS) or a backend stack rather than going Dart-only.
How much does a Flutter/Dart developer earn in India?
₹6-20 LPA in 2026 for mid-to-senior Flutter developers, with juniors at ₹4-7 LPA and leads/principals at ₹25-40 LPA. Top payers: Razorpay (mobile team), Swiggy, NyKaa, Tata Digital, Cred, PhonePe, and Flutter consultancies (Nevercode, Somnio Software). Specialised areas (FinTech mobile, fintech KYC apps with FFI, real-time apps) sit at the upper end.
Should I learn Dart 3 features or stick with Dart 2 patterns?
Learn Dart 3, records, patterns, switch expressions, and sealed classes are not optional in 2026. Most active Flutter codebases are on Dart 3.3+ and use these features pervasively. The minimum Dart version supported by current Flutter releases is well past 3.0, so legacy-only patterns are increasingly a flag in code review.
How does Dart compare to Kotlin or Swift for mobile development?
Dart is the language of Flutter, so it competes with Kotlin+Jetpack Compose (Android) and Swift+SwiftUI (iOS) at the platform level. Single-platform native apps still get a slight edge on platform-specific polish and performance ceiling. Flutter wins on cross-platform code-sharing, one codebase covering iOS, Android, web, and desktop, and on UI consistency across platforms. For startups optimising for engineer time, Flutter usually wins; for platform-flagship apps (banking apps with deep native SDK integration), native still wins.
What's the best way to learn Dart for a Flutter interview?
Build a non-trivial app, a real one, not a counter demo. Pick something with auth, list-with-pagination, a form, and offline state, and ship it end-to-end. Then read `effective Dart` (the official style guide), do the official 'Dart language tour', and practise patterns + records + sealed classes specifically since they trip up candidates who learned Dart 2 first. For the interview itself: be ready to explain null safety in depth, walk through async/await + Streams + Isolates, and discuss state management trade-offs.
How long should I prepare, and what changes between fresher and experienced Flutter interviews?
Two to four weeks of focused prep if you already write Dart daily, six to eight weeks if you are switching from Kotlin, Swift or React Native. Fresher rounds stay on language mechanics plus one machine round: null safety, `final` versus `const`, `List` and `Map` operations, `async`/`await`, a small widget-tree exercise, and one DSA question written in Dart. Experienced rounds from about three years upward skip most of that and go straight to judgement calls: how you split feature modules, why you chose Riverpod or Bloc, how you handle offline state and token refresh, how you found and fixed the last production jank or memory leak, and what your CI actually enforces (`dart analyze --fatal-infos`, `flutter test --coverage`, golden tests, staged rollouts). Most Indian product companies run one machine coding round, one mobile system-design round, and one deep dive on an app you shipped, so bring real numbers: bundle size, cold start time, crash-free sessions, and one performance problem you measured before and after.
Introduction
Dart is the language behind Flutter, and in 2026, that is essentially what Dart jobs are. Originally launched by Google in 2011 as a JavaScript alternative for the browser, Dart found its real product-market fit when Flutter shipped in 2018. Today the vast majority of Dart developers are building cross-platform mobile and web apps, with iOS, Android, web, desktop, and embedded targets compiling from a single codebase.
If you're interviewing for a Dart/Flutter role in India today, expect deep questions on sound null safety, async/await and Futures, Streams, isolates for compute, mixins and extensions, and the Dart 3.x feature set (records, patterns, switch expressions, sealed classes, and class modifiers). Most interviewers also probe Flutter-specific Dart: widget lifecycle, state management with Riverpod/Provider/Bloc, and the implicit-cast-to-`dynamic` gotchas that bite new teams.
This guide covers the 40 most-asked Dart interview questions in 2026, weighted heavily toward the Flutter context that drives most hiring, and grouped basic to advanced so you can start where you are. Alongside the language fundamentals it covers testing (`testWidgets`, golden files, `flutter test --coverage`), production debugging (frame timings, DevTools memory snapshots, `leak_tracker`), isolate messaging costs, and the language changes from Dart 3.0 through the 3.x line. Each answer includes the underlying concept, common gotchas, and code examples where they add clarity. Salaries for Flutter developers in India sit at ₹6-20 LPA, with senior engineers at fintech (Razorpay, Cred) and consumer-app companies (Swiggy, NyKaa, Tata Digital) at the upper end.
Ready to practice Dart interviews?
Don't just read, practice these Dart questions live with an AI interviewer that asks follow-ups and scores your answers.