Flutter Interview Questions and Answers: 60 Questions from Easy to Hard
This guide is for Flutter developers in India preparing for interviews, from freshers applying for their first mobile developer role to engineers with several years of experience moving into senior positions. It has 60 questions split into easy, medium and hard levels, each with a sample answer written the way a strong candidate would explain it, plus Dart code where it helps. Topics follow Flutter 3 and Dart 3: widgets, layout, navigation, state management with Provider, Riverpod and Bloc, async code, isolates, platform channels, performance, testing and release builds. Read each answer, then practise explaining it aloud in your own words.
What Flutter interviews cover
- Dart fundamentals: null safety, final versus const, async and await, Futures and Streams, mixins, and Dart 3 features such as records, patterns and sealed classes.
- Widget basics and the lifecycle: stateless versus stateful widgets, setState, BuildContext, keys, and how the widget, element and render object trees work together.
- Layout and UI: Row, Column, Expanded, constraints, responsive design with LayoutBuilder, smoothly scrolling lists, forms, and implicit and explicit animations.
- State management and navigation: Provider, Riverpod or Bloc, plus go_router for deep links and login guards. Expect to explain which one you used and why.
- Production topics for experienced roles: performance profiling, isolates, platform channels, testing, flavors, app size, keeping keys secure, and publishing to the Play Store and App Store.
How freshers should prepare
- Build two or three small but complete apps, such as a job search or expense tracker, that call a real API, show loading and error states, and save data locally.
- Learn Dart properly before Flutter tricks: null safety operators, collections, classes, async code and Streams come up in almost every written or live coding round.
- Be able to explain the StatefulWidget lifecycle, setState and BuildContext in your own words, and sketch the widget tree of one of your own screens.
- Pick one state management approach, usually Provider or Riverpod, and use it in a project, so you can answer follow-up questions from real experience instead of theory.
- Put your code in a public Git repository with a clear README and screenshots, and be ready to walk the interviewer through any file and explain your choices.
How experienced candidates should prepare
- Prepare two stories about production problems you solved, such as janky scrolling, a memory leak or a crash spike, with the tools you used and the measured result.
- Be ready to justify your architecture: folder structure, layers, dependency injection, and why you chose Bloc or Riverpod for that particular team and app.
- Revise rendering and performance: the frame budget, UI versus raster thread, const widgets, image decoding sizes, and moving heavy parsing into isolates.
- Know your release process end to end: flavors, signing, versioning, obfuscation, crash reporting with symbols, staged rollouts and CI pipelines that run tests on every change.
- Practise a machine-coding round: build a paginated list with search, error handling and a few tests in 60 to 90 minutes, explaining decisions as you go.
Common mistakes to avoid
- Calling the API or creating a Future inside build, which fires repeated requests and makes the screen flicker back to loading on every rebuild.
- Forgetting to dispose controllers, timers and stream subscriptions, and using context or setState after an await without first checking mounted.
- Putting all logic inside widgets with large setState calls instead of separating UI from business logic, which makes the code hard to test and extend.
- Judging performance in debug mode on an emulator. Always profile in profile mode on a real low-cost Android phone, like the ones many users in India carry.
- Answering with slogans such as 'Bloc is the best' without trade-offs. Interviewers want a clear reason tied to app size, team experience and testing needs.
Flutter interview questions: Easy questions (freshers)
1. What is Flutter, and how is it different from React Native?
Flutter is Google's open-source UI toolkit for building Android, iOS, web and desktop apps from one Dart codebase. The main difference is rendering. Flutter does not use the platform's native buttons and text fields; it draws every pixel itself with its own engine (Impeller on iOS and modern Android), so the UI looks the same on every device. React Native writes UI in JavaScript and maps components to native views. In release builds Flutter compiles Dart ahead of time to native machine code. The trade-offs: Flutter gives consistent visuals and smooth animations, but apps are somewhat larger and native features without a plugin need platform channels.
2. What is the difference between a StatelessWidget and a StatefulWidget?
A StatelessWidget has no mutable state. Its output depends only on its constructor arguments and inherited data such as the theme, so it changes only when its parent rebuilds it with new values. A StatefulWidget is paired with a separate State object that survives rebuilds and can hold changing data, like a counter, form input or a loaded list. Calling setState inside that State schedules a rebuild. The widget itself stays immutable; the State object is what persists. Use stateless widgets by default and choose stateful only when the widget owns data that changes over time.
class Counter extends StatefulWidget {
const Counter({super.key});
@override
State<Counter> createState() => _CounterState();
}
class _CounterState extends State<Counter> {
int count = 0;
@override
Widget build(BuildContext context) => TextButton(
onPressed: () => setState(() => count++),
child: Text('Tapped $count times'),
);
} 3. What does setState do, and what are the common mistakes with it?
setState tells Flutter that data in this State object has changed and the widget must rebuild. You change the fields inside the callback, the framework marks the element as dirty, and build runs again in the next frame. It does not rebuild immediately, and it does not rebuild the whole app, only this widget and its subtree. Common mistakes are calling it after the widget is disposed (check mounted after an await), calling it inside build, and doing slow work inside the callback. Keep the callback to the actual state change and keep stateful widgets small so rebuilds stay cheap.
4. What is BuildContext?
BuildContext is a handle to a widget's position in the widget tree; under the hood it is the widget's Element. Flutter uses it to look upward for inherited data, which is how Theme.of(context), MediaQuery.of(context) and Navigator.of(context) work. Every widget gets its own context, so position matters: a context taken above a Scaffold cannot find that Scaffold. The usual fix is to wrap the child in a Builder to get a context below it. A context also must not be used after its widget leaves the tree, which is why you check context.mounted after an await.
5. What is the difference between hot reload and hot restart?
Hot reload injects the changed Dart code into the running app and rebuilds the widget tree while keeping the current state, so you see a UI change in about a second without losing the screen you were on. It does not rerun main() or initState, so changes there do not show up. Hot restart discards all state and restarts the Dart code from main(), which is slower but still much faster than a full build. Both work only in debug mode. Changes to native Android or iOS code, or adding a plugin with native code, need the app to be stopped and built again.
6. Explain the lifecycle of a StatefulWidget.
Flutter calls createState once to create the State. initState runs once, before the first build, for one-time setup such as creating controllers or starting a data fetch. didChangeDependencies runs right after initState and again whenever an InheritedWidget it depends on changes. build runs on every rebuild. didUpdateWidget runs when the parent rebuilds with a new widget of the same type, so you can react to changed properties. setState triggers rebuilds in between. deactivate runs when the element is removed from the tree, and dispose runs when it is removed for good; that is where you release controllers, timers and subscriptions.
7. How do Row and Column work, and what do mainAxisAlignment and crossAxisAlignment control?
Row lays out its children horizontally and Column lays them out vertically. The main axis is the direction of layout (horizontal for Row, vertical for Column) and the cross axis is perpendicular to it. mainAxisAlignment controls spacing along the main axis, for example start, center, spaceBetween or spaceEvenly. crossAxisAlignment aligns children on the other axis, such as start, center or stretch. mainAxisSize.min makes the Row or Column only as large as its children instead of taking all available space. If the children are too wide for a Row, debug mode shows the yellow and black overflow stripe.
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.center,
children: const [
Text('Flutter Developer'),
Icon(Icons.bookmark_border),
],
) 8. What is the difference between Expanded and Flexible?
Both are used inside a Row, Column or Flex to share the leftover space along the main axis. Expanded forces its child to fill all the space it is given; it is simply a Flexible with fit set to FlexFit.tight. Flexible with the default FlexFit.loose lets the child be smaller than the space available. Both take a flex factor: in a Row with Expanded(flex: 2) and Expanded(flex: 1), the first child gets two thirds of the free width. A very common use is wrapping long Text in Expanded inside a Row to stop overflow errors.
Row(
children: [
Expanded(flex: 2, child: Container(height: 40, color: Colors.blue)),
Expanded(flex: 1, child: Container(height: 40, color: Colors.green)),
],
) 9. When would you use a Container and when a SizedBox?
Container is a convenience widget that combines padding, margin, decoration (colour, border, rounded corners, shadow), alignment, constraints and transforms. SizedBox only sets a fixed width and/or height, or acts as an empty gap. If you only need spacing or a fixed size, use SizedBox, for example const SizedBox(height: 16), because it is lighter and can be const. One gotcha: a Container cannot take both color and decoration; doing so fails an assertion, so put the colour inside the BoxDecoration. Recent Flutter versions also add a spacing parameter on Row and Column for even gaps.
10. Why should you use ListView.builder instead of ListView with a children list?
A ListView with a children list creates every child up front, which is fine for a handful of items. ListView.builder builds items lazily: only the rows that are visible, plus a small cache area, are created, and more are built as the user scrolls. For a feed of 500 job cards that keeps memory use and start-up time low. You pass itemCount and an itemBuilder that receives the index. ListView.separated works the same way and adds a separator builder for dividers, and GridView.builder does the same for grids.
ListView.builder(
itemCount: jobs.length,
itemBuilder: (context, index) {
final job = jobs[index];
return ListTile(title: Text(job.title), subtitle: Text(job.city));
},
) 11. What is the difference between final and const in Dart?
Both create variables you cannot reassign. A final variable is set once at runtime, so its value can come from a calculation, an API or DateTime.now(). A const value must be known at compile time; it is deeply immutable, and identical const values share a single instance. In Flutter, const widgets such as const Text('Hi') are created once, and Flutter can skip rebuilding them. Note that final fixes only the reference: a final list can still have items added, while a const list cannot be modified at all.
void main() {
final now = DateTime.now(); // runtime value: fine for final
const gst = 0.18; // compile-time constant
final cities = ['Pune'];
cities.add('Delhi'); // allowed: only the reference is final
const fixed = ['Pune'];
try {
fixed.add('Delhi'); // const lists are unmodifiable
} on UnsupportedError {
print('Cannot change a const list');
}
print('$now $gst $cities ${identical(const [1], const [1])}'); // ... true
} 12. How does null safety work in Dart, and what do ?, !, ?? and late mean?
Dart has sound null safety: a variable of type String can never be null, while String? can. The compiler makes you handle the null case before using a nullable value. The ?. operator calls a member only when the value is not null, ?? supplies a default, and ??= assigns only when the variable is null. The ! operator tells the compiler a value is not null and throws at runtime if you are wrong, so use it sparingly. late declares a non-nullable variable that is set later, for example in initState; reading it before it is assigned throws a LateInitializationError.
void main() {
String? city; // nullable, starts as null
print(city?.length); // null, no crash
print(city ?? 'Remote'); // Remote
city = 'Bengaluru';
print(city.length); // 9: promoted to non-null after assignment
late final String token;
token = 'abc123';
print(token);
} 13. What is a Future, and how do async and await work?
A Future represents a value that will be available later, such as an HTTP response or a file read. Marking a function async lets you use await, which pauses that function until the Future completes without blocking the UI; other events keep being processed. Errors from an awaited Future are caught with try and catch, just like synchronous code. An async function always returns a Future, even when it returns a plain value. In a widget, you typically start the call from initState and then call setState with the result, or display it with a FutureBuilder.
Future<String> fetchTitle() async {
await Future.delayed(const Duration(milliseconds: 500));
return 'Flutter Developer';
}
Future<void> main() async {
try {
final title = await fetchTitle();
print('Loaded: $title');
} catch (e) {
print('Failed: $e');
}
} 14. What is pubspec.yaml used for?
pubspec.yaml is the project manifest. It holds the app name and description, the version (for example 1.4.0+12, where 1.4.0 is the version name and 12 is the build number), the Dart SDK constraint, dependencies and dev_dependencies, and a flutter section that registers assets and fonts. After editing it you run flutter pub get, which resolves package versions and writes pubspec.lock. A constraint like ^6.1.0 accepts any version from 6.1.0 up to, but not including, 7.0.0. For apps, commit pubspec.lock so every developer and CI build uses the same package versions.
15. How do you move to another screen and get a result back using Navigator?
Navigator manages a stack of routes. Navigator.push puts a new screen on top and Navigator.pop removes it. push returns a Future that completes with whatever value the next screen passes to pop, so you can get a result back, such as a selected city. Because the code awaits across an async gap, check context.mounted before using the context again. Larger apps that need deep links and web URLs usually move to a declarative router such as go_router, but push and pop are still the basics every interviewer expects.
Future<void> pickCity(BuildContext context) async {
final city = await Navigator.push<String>(
context,
MaterialPageRoute(builder: (_) => const CityPickerScreen()),
);
if (!context.mounted || city == null) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(city)));
}
// Inside CityPickerScreen, when the user taps a city:
// Navigator.pop(context, 'Hyderabad'); 16. What are MaterialApp and Scaffold used for?
MaterialApp usually sits at the root of the app. It sets up the theme, localisation, the Navigator and routes, and Material defaults such as text direction. Scaffold provides the basic layout of a single screen: slots for an appBar, body, floatingActionButton, drawer and bottomNavigationBar, and the right place for snack bars and bottom sheets to appear. So MaterialApp is configured once per app, while each screen normally returns its own Scaffold. Material 3 has been the default design since Flutter 3.16, so new apps get Material 3 widgets and colours unless you turn it off.
17. Why should you use const constructors for widgets?
A widget created with const is built once at compile time and the same instance is reused. When the parent rebuilds, Flutter sees that the new widget is the identical object and skips rebuilding that part of the tree. In a screen that rebuilds often, marking static pieces such as const Text('Apply now') or const SizedBox(height: 12) saves work for free. To allow this, your own widgets need a const constructor and only final fields. The analyzer lint prefer_const_constructors can point out places where const could be added.
18. How do you read text from a TextField, and why must the controller be disposed?
A TextEditingController holds the current text and selection of a TextField. You create it as a field of the State, pass it to the TextField, read controller.text when the user submits, and you can also set the text from code. It holds listeners, so you must call its dispose method in the State's dispose; otherwise it leaks memory. For forms with validation, wrap the fields in a Form with a GlobalKey<FormState>, use TextFormField with a validator, and call formKey.currentState!.validate() when the user taps submit.
class _SearchState extends State<Search> {
final _controller = TextEditingController();
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => TextField(
controller: _controller,
onSubmitted: (_) => print('Searching ${_controller.text}'),
);
} 19. What is a Stream, and how is it different from a Future?
A Future delivers a single value or error, once. A Stream delivers a sequence of values over time, such as location updates, chat messages or download progress. You consume a stream with listen or with await for, and in the UI with StreamBuilder. A stream is either single-subscription, allowing one listener (typical for file reads), or broadcast, allowing many. Always cancel a StreamSubscription in dispose. You can create your own stream with an async* generator function that yields values, as in the example.
Stream<int> countdown(int from) async* {
for (var i = from; i > 0; i--) {
await Future.delayed(const Duration(milliseconds: 200));
yield i;
}
}
Future<void> main() async {
await for (final n in countdown(3)) {
print(n); // 3, then 2, then 1
}
} 20. What are mixins in Dart, and where does Flutter use them?
A mixin lets several classes reuse a set of methods and fields without inheritance. You declare it with the mixin keyword and apply it with with. A class has only one superclass but can use many mixins. Flutter uses them often; for example, SingleTickerProviderStateMixin gives a State the vsync it needs for an AnimationController. An on clause restricts a mixin to classes of a given type. Since Dart 3, an ordinary class can no longer be used as a mixin unless it is declared as mixin or mixin class.
mixin Loggable {
void log(String msg) => print('[$runtimeType] $msg');
}
class JobService with Loggable {
void apply() => log('Application sent');
}
void main() => JobService().apply(); // [JobService] Application sent Flutter interview questions: Medium questions (1-3 years)
21. Explain the widget tree, element tree and render tree in Flutter.
Widgets are cheap, immutable descriptions of the UI and are recreated on every build. Elements are the long-lived objects that actually sit in the tree; they hold the State of stateful widgets and link each widget to its position. BuildContext is an Element. RenderObjects do the expensive work of layout, painting and hit testing. On a rebuild, Flutter compares the new widget with the old one at each element: if the runtimeType and key match, it updates the existing element and render object instead of creating new ones. That is why rebuilding widgets often is cheap, while recreating render objects is not.
22. What are Keys in Flutter, and when do you need them?
Keys tell Flutter which existing element a new widget should match. Without keys, children are matched by position and type, so in a list of stateful items, deleting or reordering an item can make state such as a checkbox or typed text stay with the wrong row. Give each item a ValueKey built from a stable ID, like ValueKey(job.id). ObjectKey uses object identity, a UniqueKey equals only itself (so creating one in build forces a fresh element each time), and GlobalKey is unique across the app and gives access to a widget's State, as with Form. Do not use the list index as a key when items can move.
ListView(
children: [
for (final job in jobs)
JobTile(key: ValueKey(job.id), job: job),
],
) 23. What is an InheritedWidget, and how does Provider relate to it?
InheritedWidget is Flutter's built-in way to share data down the tree efficiently. A descendant calls dependOnInheritedWidgetOfExactType through its context, which both returns the widget and registers a dependency. When the InheritedWidget is rebuilt and updateShouldNotify returns true, only the registered dependents rebuild. Theme.of and MediaQuery.of work this way. Provider is largely a friendlier layer over InheritedWidget: it adds creation and disposal of objects, lazy loading and listening to ChangeNotifier, so you get the same efficient lookups without writing the boilerplate yourself.
24. In Provider, what is the difference between context.watch, context.read and context.select?
context.watch<T>() returns the object and rebuilds the widget every time it notifies listeners. context.read<T>() returns it without subscribing, so it belongs in callbacks such as onPressed, not in build. context.select<T, R>() subscribes to one derived value, so the widget rebuilds only when that value changes, for example only the cart count. Consumer behaves like watch but limits the rebuild to its builder, which helps inside a large build method. A classic bug is using read in build and then wondering why the UI never updates.
class Cart extends ChangeNotifier {
final List<String> items = [];
void add(String item) {
items.add(item);
notifyListeners();
}
}
// Inside build(): rebuilds only when the count changes
// final count = context.select<Cart, int>((c) => c.items.length);
// Inside a button callback: no subscription needed
// onPressed: () => context.read<Cart>().add('Course'), 25. Why would you choose Riverpod over Provider?
Riverpod, from the same author as Provider, declares providers as global final variables instead of placing them in the widget tree. They do not depend on BuildContext, so you cannot hit a 'provider not found' error at runtime, and you can read them from anywhere you have a ref. In widgets, ref.watch rebuilds on change, ref.read is for callbacks, and ref.listen handles side effects such as a snackbar. It also offers auto-dispose, parameterised providers with family, easy overrides in tests, and providers that combine other providers. The app is wrapped once in a ProviderScope.
final counterProvider = NotifierProvider<Counter, int>(Counter.new);
class Counter extends Notifier<int> {
@override
int build() => 0;
void increment() => state++;
}
class CounterText extends ConsumerWidget {
const CounterText({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) =>
Text('${ref.watch(counterProvider)}');
} 26. What is the difference between a Bloc and a Cubit?
Both come from the bloc package and move business logic out of widgets into classes that emit states. A Cubit exposes plain methods that call emit with a new state, which is simple and enough for most screens. A Bloc receives events through add and maps them to states in on<Event> handlers. That gives you a traceable log of every event and lets you control concurrency with transformers, such as restartable for search-as-you-type. In the UI, BlocBuilder rebuilds on state changes and BlocListener handles one-off actions like navigation or a snackbar.
class CounterCubit extends Cubit<int> {
CounterCubit() : super(0);
void increment() => emit(state + 1);
}
// In the UI:
// BlocBuilder<CounterCubit, int>(
// builder: (context, count) => Text('$count'),
// ) 27. Why should you not create the Future inside build when using FutureBuilder?
FutureBuilder listens to whatever Future you pass it. If you call fetchJobs() directly inside build, every rebuild (keyboard opening, theme change, a parent's setState) creates a new Future and fires a new API request, and the UI flashes back to the loading state. Create the Future once, in initState, store it in a field, and pass that field to FutureBuilder. Handle every state: loading, error and data. For data that is shared between screens or needs caching and refresh, a state management tool such as a Riverpod FutureProvider is cleaner.
late final Future<List<String>> _jobs;
@override
void initState() {
super.initState();
_jobs = fetchJobs(); // runs once, not on every build
}
@override
Widget build(BuildContext context) => FutureBuilder<List<String>>(
future: _jobs,
builder: (context, snap) {
if (snap.hasError) return Text('Error: ${snap.error}');
if (!snap.hasData) return const CircularProgressIndicator();
return Text('${snap.data!.length} jobs');
}); 28. What is Navigator 2.0, and why do teams use go_router?
Navigator 1 is imperative: you push and pop routes in code. Navigator 2, the Router API, is declarative: the stack of pages is derived from app state and the URL. That is what you need for web URLs, deep links from notifications or emails, and correct back-button behaviour. The raw API is verbose, so most teams use go_router, a package maintained by the Flutter team. You declare paths with parameters, use context.go to jump to a location and rebuild the stack, use context.push to add a page on top, and add a redirect for login checks.
final router = GoRouter(
routes: [
GoRoute(path: '/', builder: (context, state) => const HomeScreen()),
GoRoute(
path: '/job/:id',
builder: (context, state) =>
JobScreen(id: state.pathParameters['id']!),
),
],
);
// MaterialApp.router(routerConfig: router)
// Navigate: context.go('/job/42'); 29. What are isolates, and when should you use one in a Flutter app?
Your Dart code runs on a main isolate, which also builds the UI. Heavy CPU work there, such as parsing a 5 MB JSON response, resizing an image or encrypting a file, blocks frames and makes the app stutter. An isolate is a separate Dart worker with its own memory; isolates share no objects and communicate by passing messages. Isolate.run, or Flutter's compute function, starts an isolate, runs your function, returns the result and shuts it down. Remember that async and await alone do not help here: they wait for I/O, but CPU work still runs on the main isolate.
import 'dart:convert';
import 'dart:isolate';
Future<List<dynamic>> parseJobs(String body) {
// jsonDecode runs on a background isolate, so the UI stays smooth
return Isolate.run(() => jsonDecode(body) as List<dynamic>);
} 30. How does the Dart event loop work? What does this code print?
Each isolate runs an event loop with two queues. The microtask queue holds small internal tasks, such as scheduleMicrotask callbacks and work scheduled when a Future completes, and it is always emptied first. The event queue holds outside events: timers, I/O, user input, and callbacks from Future(() {...}). Synchronous code runs to completion first, then all microtasks, then one event, then microtasks again. So the code prints 1, 2, 3, 4. A practical consequence: flooding the microtask queue can delay taps and frames, so long work belongs in an isolate.
import 'dart:async';
void main() {
print('1');
Future(() => print('4'));
scheduleMicrotask(() => print('3'));
print('2');
}
// Output: 1 2 3 4 31. How do you run several API calls in parallel instead of one after another?
Awaiting calls one by one runs them in sequence, so three one-second calls take three seconds. If the calls are independent, start them together and await Future.wait, which returns all results in the same order once every Future finishes; the total time is close to the slowest call. If one fails, Future.wait by default still waits for the rest and then reports the first error; pass eagerError: true to fail immediately. In Dart 3 you can also call .wait on a record of futures, which keeps each result's own type.
Future<int> slow(int v) =>
Future.delayed(const Duration(seconds: 1), () => v);
Future<void> main() async {
final sw = Stopwatch()..start();
final results = await Future.wait([slow(1), slow(2), slow(3)]);
print('$results in ${sw.elapsed.inSeconds}s'); // [1, 2, 3] in 1s
} 32. When would you use LayoutBuilder instead of MediaQuery for a responsive layout?
MediaQuery describes the whole screen and device: size, safe-area padding, text scale, orientation and brightness. LayoutBuilder gives the constraints from the parent, which is the space this particular widget actually has. For responsive layouts, LayoutBuilder is usually the better choice, because a widget inside a side panel or dialog should adapt to its own width, not the screen's. When you do need MediaQuery for one value, use MediaQuery.sizeOf(context) or MediaQuery.paddingOf(context) instead of MediaQuery.of(context), so the widget rebuilds only when that value changes, not on every keyboard or inset change.
LayoutBuilder(
builder: (context, constraints) {
if (constraints.maxWidth >= 600) {
return const TwoPaneJobsView();
}
return const JobsListView();
},
) 33. Why does a ListView inside a Column throw an unbounded height error, and how do you fix it?
Flutter layout follows one rule: constraints go down, sizes go up, and the parent decides the position. A Column gives its children unlimited height along its main axis, and a ListView tries to take all the height it is allowed along its scroll direction. Infinite height cannot be laid out, so you get 'Vertical viewport was given unbounded height'. Wrap the ListView in Expanded so it takes the remaining space, or give it a fixed height with SizedBox. shrinkWrap: true also removes the error, but the list then measures all its items and loses lazy building, so avoid it for long lists.
Column(
children: [
const Text('Jobs near you'),
Expanded(
child: ListView.builder(
itemCount: jobs.length,
itemBuilder: (context, i) => Text(jobs[i]),
),
),
],
) 34. What are platform channels, and when do you need them?
Platform channels let Dart call native Kotlin or Java on Android and Swift or Objective-C on iOS when no plugin covers what you need, such as a bank's or vendor's native SDK. A MethodChannel sends an asynchronous call with a method name and arguments and receives one reply. An EventChannel streams events from native code to Dart, such as sensor readings. Both sides must use the same channel name. Arguments go through a standard codec, so only simple types, lists, maps and byte data pass. Native errors arrive in Dart as PlatformException. For larger APIs, Pigeon generates type-safe code for both sides.
import 'package:flutter/services.dart';
const _channel = MethodChannel('com.example.app/battery');
Future<int?> batteryLevel() async {
try {
return await _channel.invokeMethod<int>('getBatteryLevel');
} on PlatformException catch (e) {
print('Native call failed: ${e.message}');
return null;
}
} 35. Why is it risky to use BuildContext or setState after an await, and how do you handle it?
While the await is waiting, the user may leave the screen, so the widget may already be disposed when the code continues. Calling setState then throws an error, and using the old context for Navigator or a snackbar can act on a screen that no longer exists. Inside a State, check mounted right after the await and return if it is false. In a function that receives a BuildContext, check context.mounted, available since Flutter 3.7. The lint use_build_context_synchronously catches many of these cases. Also cancel timers and subscriptions in dispose so late callbacks never fire.
Future<void> _submit() async {
setState(() => _loading = true);
final ok = await api.apply(widget.jobId);
if (!mounted) return; // the user left this screen
setState(() => _loading = false);
if (ok) Navigator.pop(context);
} 36. What is the difference between unit, widget and integration tests in Flutter?
Unit tests check one function or class, such as a validator, repository or Cubit, with no UI; they are fast and should be the majority. Widget tests render a widget in a test environment without a device: WidgetTester lets you tap, type and check what is on screen, pump() renders one frame and pumpAndSettle() waits for animations to finish. Integration tests use the integration_test package to run the whole app on a real device or emulator and test flows like login to apply. A healthy suite has many unit tests, a solid set of widget tests and a few integration tests for key journeys. The example assumes a CounterPage widget from your app.
testWidgets('counter increments', (tester) async {
await tester.pumpWidget(const MaterialApp(home: CounterPage()));
expect(find.text('0'), findsOneWidget);
await tester.tap(find.byIcon(Icons.add));
await tester.pump();
expect(find.text('1'), findsOneWidget);
}); 37. What are records, patterns and sealed classes in Dart 3, and why are they useful?
Dart 3 added records, patterns and class modifiers. A record groups values without writing a class, such as (String, int), which is handy for returning two values from a function. Patterns destructure records, lists, maps and objects in declarations and switch statements. A sealed class can only be subclassed in its own library, so the compiler knows every subtype. A switch over it must be exhaustive, so adding a new subtype gives a compile error wherever you forgot to handle it. This is a clean way to model API results or screen states.
sealed class Result {}
class Success extends Result { Success(this.data); final String data; }
class Failure extends Result { Failure(this.error); final String error; }
String describe(Result r) => switch (r) {
Success(:final data) => 'Got $data',
Failure(:final error) => 'Error: $error',
};
void main() {
print(describe(Success('42 jobs'))); // Got 42 jobs
final (name, age) = ('Asha', 24);
print('$name is $age'); // Asha is 24
} 38. Which objects must you dispose in a StatefulWidget, and what happens if you forget?
Release anything that holds listeners, timers or native resources in the State's dispose method: TextEditingController, ScrollController, AnimationController, FocusNode, StreamSubscription, Timer, and any ChangeNotifier you created yourself. If you forget, their callbacks keep the State and its context alive after the screen closes, memory grows each time the user visits the screen, and old callbacks may still fire and call setState on a dead widget. A simple rule: whatever you create in initState, you clean up in dispose, and you call super.dispose() last.
39. What is the difference between implicit and explicit animations in Flutter?
Implicit animations are widgets such as AnimatedContainer, AnimatedOpacity and AnimatedSwitcher: you change a property, set a duration and a curve, and Flutter animates from the old value to the new one. They need no controller and cover most UI polish. Explicit animations use an AnimationController that you start, stop, reverse or repeat, combined with Tweens and widgets such as FadeTransition or AnimatedBuilder. You need them for looping, staggered or gesture-driven effects. The controller needs a vsync from SingleTickerProviderStateMixin and must be disposed.
AnimatedContainer(
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
width: selected ? 200 : 120,
color: selected ? Colors.orange : Colors.grey,
child: const Text('Remote'),
) 40. How do you manage different environments such as dev, staging and production in a Flutter app?
Use compile-time values so one codebase builds for every environment without editing code. Pass values with --dart-define=API_URL=... or from a file with --dart-define-from-file, and read them with String.fromEnvironment, which must be called as const. When each environment also needs its own app ID, name or icon, set up flavors (productFlavors on Android, schemes on iOS) and build with --flavor staging. These values are compiled into the app and can be read by anyone who unpacks it, so never use them for real secrets.
const apiUrl = String.fromEnvironment(
'API_URL',
defaultValue: 'https://staging.example.com',
);
void main() => print('Using $apiUrl');
// flutter run --dart-define=API_URL=https://api.example.com Flutter interview questions: Hard questions (experienced)
41. Walk me through how Flutter renders a frame. Where does jank come from?
For each frame, the UI thread, where your Dart code runs, handles input and animation ticks, then the build phase rebuilds dirty widgets, layout sizes the render objects, and paint records drawing commands into layers. The layer tree goes to the raster thread, which turns it into GPU commands using Impeller or Skia. At 60 Hz each frame has about 16 ms, at 120 Hz about 8 ms, and both threads must finish in time. DevTools' performance view shows which one is late: a slow UI thread means too much Dart work or rebuilding, while a slow raster thread points to expensive painting such as saveLayer, clips, shadows or large images.
42. A long job list scrolls with jank on low-end Android phones. How do you find and fix the cause?
First reproduce it in profile mode on a real low-cost phone, never in debug mode, which is far slower. Record the scroll in DevTools and see whether the UI or raster thread misses the frame budget. Typical fixes: build items lazily with ListView.builder and set itemExtent or prototypeItem so the list skips measuring; split item widgets and use const so tapping a save button does not rebuild the whole list; decode images at display size with cacheWidth instead of full-resolution photos; avoid Opacity and heavy shadows on every row; and move JSON parsing to an isolate. Measure again after each change.
Image.network(
job.logoUrl,
width: 48,
height: 48,
cacheWidth: 96, // decode at about 2x display size, not full size
) 43. Why do experienced Flutter developers prefer small widget classes over helper methods that return widgets?
A method like Widget _buildHeader() runs as part of the parent's build, so every time the parent rebuilds, the header is rebuilt too, and it can never be const. Extracting it into its own StatelessWidget gives it its own element and BuildContext: Flutter can skip it when the instance is identical, and it rebuilds alone when its own inherited dependencies change. It also gets the correct context, for example one that sees a Theme or Provider inserted just above it. Helper methods are fine for tiny fragments; for anything with its own dependencies or a costly subtree, use a widget class.
44. When is a GlobalKey needed, and what are its costs?
A GlobalKey is unique across the whole app and exposes the element's State (currentState), widget and context from outside. You need one for Form validation, for direct access to a NavigatorState, and for moving a stateful subtree to a new parent without losing its state. The costs: Flutter keeps a global registry of these keys, moving a subtree between parents is more expensive than a normal update, and creating a new GlobalKey inside build destroys and recreates the subtree on every rebuild, wiping its state. Create it once as a field. For most cases, a callback, controller or state management solution is cleaner than reaching into another widget's State.
45. What is the difference between didChangeDependencies and didUpdateWidget, and what bug happens if you ignore didUpdateWidget?
didChangeDependencies runs after initState and again whenever an InheritedWidget this State depends on changes, such as Theme, MediaQuery, Localizations or a watched Provider. It is the right place for work that needs inherited lookups, which are not allowed inside initState. didUpdateWidget(oldWidget) runs when the parent rebuilds with a new configuration of the same widget type and key. Compare oldWidget with widget and react, for example by refetching when jobId changes. The common bug is loading data only in initState, so the screen keeps showing the old job when the parent passes a new ID. No setState is needed there, because build runs right after.
@override
void didUpdateWidget(covariant JobDetails oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.jobId != widget.jobId) {
_future = repo.fetchJob(widget.jobId);
}
} 46. How would you structure a large Flutter app that several developers work on?
I organise by feature, not by file type: features/jobs, features/profile, and so on, each with data, domain and presentation layers. The data layer holds API clients, DTOs and repositories that hide whether data comes from the network or a local cache. The domain layer holds plain Dart models and, where it adds value, use cases. The presentation layer holds widgets plus Cubits, Blocs or Riverpod notifiers that talk to repositories, never to the HTTP client directly. Dependencies are injected through Riverpod or get_it so tests can swap in fakes. Shared core modules handle networking, theming and errors, and CI enforces lints and tests. I avoid adding layers to small features that do not need them.
47. How would you design an offline-first feature, such as saving and applying to jobs without network?
I treat the local database as the source of truth for the UI. Screens read from a local store such as drift or sqflite through streams, so they work offline and update automatically. Network responses are written into that store, not directly into the UI. Actions taken offline, like saving a job or applying, go into an outbox table with a client-generated ID and are retried with backoff when connectivity returns. The server must treat that ID as an idempotency key so retries never create duplicate applications. I decide a conflict rule per entity, for example last write wins using server timestamps, and show a clear sync status to the user.
48. What is the difference between a single-subscription and a broadcast stream? When does 'Bad state: Stream has already been listened to' occur?
A default StreamController creates a single-subscription stream: it buffers events until the first listener arrives and allows only one listener. Listening a second time throws 'Bad state: Stream has already been listened to'. A broadcast controller allows many listeners but does not buffer, so a listener that subscribes late misses earlier events. Use single-subscription for one consumer that must not lose data, such as reading a file, and broadcast for app-wide events like login changes. Always close controllers and cancel subscriptions. When new listeners need the latest value immediately, use a ValueNotifier, a Bloc or Riverpod state, or a BehaviorSubject from rxdart.
import 'dart:async';
void main() {
final controller = StreamController<int>.broadcast();
controller.stream.listen((v) => print('A got $v'));
controller.stream.listen((v) => print('B got $v'));
controller.add(1); // both listeners receive 1
controller.close();
} 49. How do you implement search-as-you-type without flooding the API or showing stale results?
Debouncing waits until the user stops typing for about 400 ms before calling the API, which cuts requests from one per keystroke to one per pause. But responses can still arrive out of order: the reply for 'flu' may come after the reply for 'flutter' and overwrite the correct results. Fix that by tagging each request and ignoring any response that is not for the latest query, or by cancelling the previous request, for example with a cancel token in your HTTP client. In Bloc, the restartable event transformer gives the same protection.
import 'dart:async';
Timer? _debounce;
int _latest = 0;
void onQueryChanged(String q) {
_debounce?.cancel();
_debounce = Timer(const Duration(milliseconds: 400), () async {
final id = ++_latest;
final results = await searchJobs(q); // your API call
if (id != _latest) return; // a newer search started: drop this one
showResults(results);
});
} 50. Explain how isolates communicate and their limits, including on the web and with plugins.
Isolates share no memory; each has its own heap and event loop, and they talk through SendPort and ReceivePort. Messages are copied, so sending a large object costs time. Isolate.exit, which Isolate.run uses internally, hands the final result to the receiving isolate without a copy, and TransferableTypedData moves large byte buffers cheaply. Starting an isolate has a cost, so for repeated work keep one long-lived worker and send it jobs. Since Flutter 3.7, background isolates can call platform channels after initialising BackgroundIsolateBinaryMessenger. On the web there are no real isolates: compute runs on the main thread, so web needs web workers or smaller chunks of work.
51. When would you choose dart:ffi over platform channels for native code?
Platform channels are asynchronous and serialise every message between Dart and Kotlin or Swift. That is fine for occasional calls, like opening a native SDK screen, but adds latency and copying for frequent calls or large data. dart:ffi calls C functions directly and synchronously with no serialisation, which suits C or C++ libraries for image processing, cryptography or SQLite. The ffigen tool generates bindings from header files. The cost is that you manage native memory yourself, for example with malloc and free or an Arena, and you must ship compiled libraries for each CPU architecture. For Android and iOS platform APIs, jnigen and Pigeon reduce channel boilerplate. Choose by call frequency, data size and whether the native code is C.
52. Your release APK is too large. How do you reduce the size of a Flutter app?
First measure: build with --analyze-size for one target platform and open the result in the DevTools app size tool to see whether Dart code, assets, fonts or native libraries dominate. Then act on the biggest items. Publish an Android App Bundle so the Play Store delivers only the code for each device's CPU architecture. Compress and resize images, prefer WebP or vector assets, and ship only the font weights you use. Review heavy packages. Keep icon tree shaking on; it is on by default in release builds. Use --split-debug-info, which moves symbols out of the binary, along with --obfuscate. On Android, deferred components can download rarely used features later.
flutter build apk --release --analyze-size --target-platform android-arm64
flutter build appbundle --release --obfuscate --split-debug-info=build/symbols 53. How do you keep API keys and user tokens secure in a Flutter app?
Assume anything shipped inside the app can be extracted: --dart-define values, assets and even obfuscated Dart code can be recovered with a decompiler and some patience. Obfuscation only slows that down. So real secrets, such as a payment gateway secret key or a paid third-party API key, must stay on your backend; the app calls your server and your server calls the third party. On the device, store user tokens with flutter_secure_storage, which uses the Keychain on iOS and Keystore-backed encryption on Android, never in SharedPreferences. Use HTTPS everywhere, short-lived access tokens with refresh tokens, certificate pinning for high-risk apps, and authorisation checks on the server for every request.
54. How do you unit test business logic that depends on an API repository?
I test logic against fakes, never the real network. With mocktail, I create a mock that implements the repository interface, stub its methods with when, run the Cubit or notifier, and then check the resulting state and verify the call. For Blocs, blocTest from bloc_test checks the exact sequence of emitted states. Widget tests reuse the same mocks through Provider or Riverpod overrides. Golden tests compare a widget's rendering with a saved image to catch visual regressions, and a few integration_test flows run on a device in CI. I keep tests deterministic: no real network, no sleeps, and fixed clocks.
class MockJobRepo extends Mock implements JobRepository {}
void main() {
test('load emits jobs from the repository', () async {
final repo = MockJobRepo();
when(() => repo.fetchJobs()).thenAnswer((_) async => ['Flutter Dev']);
final cubit = JobsCubit(repo);
await cubit.load();
expect(cubit.state, ['Flutter Dev']);
verify(() => repo.fetchJobs()).called(1);
});
} 55. In Riverpod, how do you avoid unnecessary rebuilds and manage provider lifetimes?
Use ref.watch only in build, to subscribe; ref.read in callbacks, to read once; and ref.listen for side effects such as navigation or a snackbar, which should never happen during build. To limit rebuilds, watch one field with select, for example ref.watch(profileProvider.select((p) => p.name)), so the widget ignores other changes. autoDispose frees a provider's state when nothing listens to it, which suits screen-level data and search results; use ref.keepAlive when you want to cache it. family creates one provider instance per argument, like jobDetailsProvider(jobId). In tests, override providers in ProviderScope to inject fakes.
56. How do Bloc event transformers help with concurrency problems?
Since bloc 7.2, a Bloc processes events concurrently by default. That can cause races: two taps on Submit send two applications, or an older search result overwrites a newer one. The bloc_concurrency package offers per-event transformers: sequential handles events one at a time, in order; droppable ignores new events while one is running, which suits submit or pay buttons; restartable cancels the running handler when a new event arrives so its later emits are ignored, which suits search; and concurrent is the default. You can also build a custom transformer, for example a debounce combined with switchMap from rxdart.
class SearchBloc extends Bloc<SearchEvent, SearchState> {
SearchBloc(this.repo) : super(const SearchState.initial()) {
on<QueryChanged>(_onQuery, transformer: restartable());
on<ApplyPressed>(_onApply, transformer: droppable());
}
final JobRepository repo;
// _onQuery and _onApply are defined below
} 57. How do you protect routes behind login and support deep links with go_router?
Put the login check in the router's redirect, not in each screen. redirect runs on every navigation, including deep links; return a new location to redirect, or null to continue. Pass the requested location as a query parameter so the user returns there after logging in. Give the router a refreshListenable, such as a ChangeNotifier that fires on login and logout, so redirect runs again when auth state changes. For links to open the app, set up Android App Links (intent filters plus an assetlinks.json file on your domain) and iOS Universal Links (Associated Domains plus an apple-app-site-association file); go_router then receives the path.
final router = GoRouter(
refreshListenable: auth, // a ChangeNotifier
redirect: (context, state) {
final atLogin = state.matchedLocation == '/login';
if (!auth.isLoggedIn && !atLogin) {
return '/login?from=${Uri.encodeComponent(state.uri.toString())}';
}
if (auth.isLoggedIn && atLogin) return '/';
return null;
},
routes: appRoutes,
); 58. Memory keeps growing every time users open and close a screen. How do you find the leak?
I reproduce it by opening and closing the suspect screen several times in profile mode, then use the DevTools Memory view: take a heap snapshot before and after and compare them to see which classes keep growing, such as instances of that screen's State. The retaining path shows what holds them. Usual causes are an undisposed controller, a listener added to a long-lived ChangeNotifier or stream and never removed, a Timer.periodic that was never cancelled, or a singleton holding a closure that captured a BuildContext. The leak_tracker package can flag undisposed objects in widget tests. Large images are another cause; the image cache size can be capped through PaintingBinding.instance.imageCache.maximumSizeBytes.
59. How do you handle app lifecycle events such as the app going to the background?
Flutter reports lifecycle states: resumed (visible and interactive), inactive (visible but not receiving input, for example during an incoming call screen), hidden, paused (in the background) and detached. Listen with AppLifecycleListener, added in Flutter 3.13, or with WidgetsBindingObserver. On pause, stop location tracking, timers and video and save drafts; on resume, refresh stale data and recheck permissions or session expiry. Android may kill a backgrounded app to free memory, so in-memory state can be lost. Save important input locally, or use Flutter's restoration framework, RestorationMixin with a restorationScopeId, so users return to the same screen and values.
late final AppLifecycleListener _listener;
@override
void initState() {
super.initState();
_listener = AppLifecycleListener(
onPause: _saveDraft,
onResume: _refreshJobs,
);
}
@override
void dispose() {
_listener.dispose();
super.dispose();
} 60. Describe a reliable release process for a Flutter app on the Play Store and App Store.
Versioning lives in pubspec.yaml as version: 2.3.0+45: the version name before the plus sign and the build number after it, which must increase with every store upload. On Android, sign with an upload keystore kept outside git, enrol in Play App Signing and upload an app bundle. On iOS, manage certificates and provisioning profiles and upload the archive through Xcode or a command-line tool. In CI, run tests first, build each flavor with obfuscation, and upload the debug symbols to your crash reporter so stack traces are readable. Release in stages, for example 10% of users first, and watch the crash-free rate before going to 100%.
Ready to apply?
There are 40 open jobs asking for Flutter on CoreHRx right now. Create a free profile once and apply with it.