This page is for anyone facing a Flutter round, from a first mobile job to a senior app role. Most Flutter interviews open with widgets, the three trees, BuildContext and keys, then test state management and how you choose between setState, Provider, Riverpod and Bloc. After that come async code and isolates, layout constraints, navigation, platform channels and performance. Senior rounds add a real project story and a judgement call. Each question shows what the interviewer is checking, the shape of a strong answer and a short answer you can say out loud. Practise saying them, then swap in your own projects.
Search all questions by round, difficulty and level, or save the ones you want to practise.
Stateless: output depends only on constructor arguments and inherited data; nothing changes inside it.
Stateful: it holds data that changes over its life, like a text field value or a toggle.
Where state lives: in the State object, which the element keeps across rebuilds while the widget itself is thrown away and recreated.
"I use a StatelessWidget when everything it shows comes from its constructor or from something above it like the theme. I switch to a StatefulWidget when the widget itself owns data that changes, like whether a panel is expanded or the current page of a carousel. The key point is that the widget is always immutable, even a stateful one. The changing data lives in a separate State object. When the parent rebuilds, Flutter creates a new widget instance, but the element in that spot keeps the same State object and just points it at the new widget. That's why a counter doesn't reset to zero every time the parent rebuilds. So I keep state as low in the tree as I can, and I don't make something stateful just because it takes a callback."
Saying a StatefulWidget is mutable, or that the State object is recreated every time the widget rebuilds.
Widgets: cheap, immutable descriptions of what the UI should look like; recreated on every build.
Elements: the long-lived instances at each spot in the tree; they hold State, compare old and new widgets and decide what to update.
Render objects: do the expensive work of layout, painting and hit testing; kept and updated in place, not recreated.
Why three: rebuilding descriptions is cheap, so Flutter can rebuild often and only touch the costly layer where something really changed.
"Widgets are just configuration. They're small immutable objects, and Flutter creates new ones every time build runs. Elements are the real instances. Each element sits at one position in the tree, holds a reference to its current widget, and for stateful widgets it holds the State object. When a rebuild happens, the element compares the new widget with the old one. If the type and key match, it updates itself and passes the change down instead of tearing anything down. Render objects are the heavy layer. They handle layout, painting and hit testing, and they're only created by widgets like Padding or a Row, not by every widget. They're updated in place when their properties change. The split exists so that building is cheap. I can rebuild a whole screen of widgets and the framework only touches the render objects that actually need new layout or paint."
Saying Flutter repaints the whole screen from scratch on every setState, or that widgets are the objects doing layout.
What it is: the element for this widget, meaning its location in the tree.
Lookups go up: of(context) methods walk up through ancestors only.
Common failure: using the context of the widget that builds the Scaffold, which sits above it; fix with a Builder or a separate child widget.
Async gaps: after an await, check the widget is still mounted before using context.
"A BuildContext is a handle to the widget's element, so it's really this widget's position in the tree. Calls like Theme.of or Navigator.of start at that position and search upward through the ancestors. That's why Scaffold.of can fail. If my build method returns the Scaffold and I use that same build method's context, I'm asking from a spot above the Scaffold, so there's nothing to find. The fix is to wrap the part that needs it in a Builder, or move it into its own widget, so it gets a context below the Scaffold. The other trap is using context after an await. By the time the future finishes, the user may have left the screen and the element may be unmounted, so I check mounted first before showing a snackbar or navigating."
Treating context as a global app object that can be passed around and used anywhere, at any time.
Start: createState, then initState once, then didChangeDependencies, then build.
Updates: didUpdateWidget when the parent passes a new widget config; setState for own changes.
End: deactivate when removed from the tree, dispose when it is gone for good.
Rule: subscribe in initState, re-subscribe in didUpdateWidget if the source changed, cancel in dispose.
"The framework calls createState, then initState exactly once, which is where I start controllers and subscriptions. Next comes didChangeDependencies, which also runs later whenever an inherited widget I depend on changes, so that's the place for lookups like MediaQuery that I want to react to. Then build runs, and it can run many times. If the parent rebuilds and hands me a new widget of the same type, didUpdateWidget fires with the old widget, so I compare them and re-subscribe if the stream I was given changed. When the widget leaves the tree, deactivate runs, and if it isn't reinserted, dispose runs. That's where I cancel subscriptions and dispose controllers. If I skip that, the stream keeps calling setState on a dead State and I get an error, plus a memory leak."
class _PriceTagState extends State<PriceTag> {
StreamSubscription<double>? _sub;
double _price = 0;
void _listen() =>
_sub = widget.prices.listen((p) => setState(() => _price = p));
@override
void initState() {
super.initState();
_listen();
}
@override
void didUpdateWidget(PriceTag oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.prices != widget.prices) { _sub?.cancel(); _listen(); }
}
@override
void dispose() { _sub?.cancel(); super.dispose(); }
@override
Widget build(BuildContext context) => Text(_price.toStringAsFixed(2));
}
Starting subscriptions or API calls inside build, or never cancelling them in dispose.
Matching rule: on rebuild, an element is reused if the new widget has the same runtime type and key.
The bug: reorder or remove stateful items with no keys and the state stays by position, so it ends up on the wrong item.
Fix: a ValueKey from a stable id on the top widget of each item.
Other keys: ObjectKey, UniqueKey, and GlobalKey for reaching a State or moving a subtree, used sparingly.
"Keys control how Flutter matches new widgets to existing elements. Without keys, it matches children by position and runtime type. Say I have a list of todo rows, each a StatefulWidget holding its own checkbox state. If I delete the first row with no keys, Flutter sees one fewer widget of the same type, reuses the first element for what used to be the second item, and drops the last element. The text updates because it comes from the new widget, but the checked state stays where it was, so the wrong row looks checked. Giving each row a ValueKey with the todo's id fixes it, because now the element follows its item. The key has to go on the outermost widget of each list item, the one that's a direct child of the list. GlobalKey is different: it's unique across the app and lets me reach a State, like a form's, but it's costly so I use it rarely."
Saying keys are needed on every widget, or putting the key on a widget deep inside the item instead of the item itself.
Runs the callback: your change is applied right away, synchronously.
Marks dirty: the element is flagged and a frame is scheduled; build runs on that next frame.
Batched: several setState calls before the frame lead to one build.
After dispose: it is an error; check mounted after any await.
"setState runs the function I pass it straight away, then marks this widget's element as dirty and asks the engine for a new frame. It doesn't rebuild on the spot. On the next frame, Flutter rebuilds the dirty elements, so if I call setState three times in one handler I still get one build. Only this widget and what it returns rebuild, not the whole app. The callback should just change fields. It shouldn't be async, and I do the slow work before calling setState, not inside it. The classic problem is calling it after the widget has been disposed, usually when a network call returns after the user has left the screen. Flutter reports an error saying setState was called after dispose. The fix is checking mounted after the await, and also cancelling timers and subscriptions in dispose so they can't fire later."
Saying setState rebuilds the entire app, or that it rebuilds immediately inside the call.
Lookup: a descendant calls dependOnInheritedWidgetOfExactType, gets the nearest one above and registers as a dependent.
Notify: when the inherited widget is replaced and updateShouldNotify returns true, only dependents rebuild.
Provider adds: creating and disposing the object, lazy creation, listening to a ChangeNotifier, and simple read or watch calls.
"An InheritedWidget sits high in the tree and holds some data. When a widget below calls dependOnInheritedWidgetOfExactType, which is what Theme.of does under the hood, it gets the nearest one above it quickly and gets registered as a dependent. When the inherited widget is rebuilt with new data, Flutter calls updateShouldNotify, and if that returns true, only the registered dependents rebuild, not everything in between. The catch is that raw InheritedWidget is immutable and a bit verbose, and it doesn't manage the life of the object it holds. Provider wraps that same mechanism. It creates the object, disposes it when the provider leaves the tree, can create it lazily, and with ChangeNotifierProvider it listens to the notifier and triggers the rebuild for me. In widgets I use context.watch when I want to rebuild on changes and context.read inside callbacks when I just need the object."
Believing every widget between the provider and the consumer rebuilds when the value changes.
Split the state: local UI state stays in setState; shared app state needs a proper tool.
Weigh the app: size, how many screens share data, testing needs, async complexity.
Weigh the team: what people know, how much structure and boilerplate they will accept.
Commit: pick one for app state, write the conventions down, stay consistent.
"First I separate two kinds of state. Things like whether a dropdown is open or which tab is selected are local, and setState is perfectly fine for them. The real decision is for shared app state, like the signed-in user, a cart or data loaded from an API. For a small app with a few shared objects, Provider with ChangeNotifier is simple and well understood. If the app is bigger and has lots of async data that depends on other data, I like Riverpod because providers don't depend on the widget tree and are easy to override in tests. If the team wants strict structure, with every change going through an explicit event and a clear log of state transitions, Bloc fits well, at the cost of more boilerplate. I'd also weigh what the team already knows. Whatever we pick, the big win is consistency, so I'd write down the conventions early."
Declaring one library the best for every app, or putting every bit of UI state into a global store.
Flow: the UI adds events; the bloc handles each event and emits new states; the UI rebuilds from state.
States: immutable objects, often compared by value so identical states do not rebuild.
Widgets: BlocBuilder to rebuild, BlocListener for one-off actions like navigation or a snackbar.
Cubit: same idea without events; public methods call emit directly.
"With Bloc, the UI never changes state directly. It adds events, like LoginSubmitted with the email and password. The bloc registers a handler for each event type, does the work, and emits new states, like loading, then success or failure. States are immutable, and I usually make them compare by value so emitting the same state twice doesn't rebuild anything. On the UI side, BlocBuilder rebuilds from the current state, and BlocListener handles one-off side effects like navigating after login or showing a snackbar, which should never happen inside build. A Cubit is the lighter version. There are no event classes; the UI calls methods like increment or load, and those methods call emit. I start with a Cubit for simple features and use a full Bloc when I want the event log, or need event handling control like dropping repeated taps or debouncing search input."
Mutating a state object and emitting it again, then wondering why the UI did not update.
Not tied to the tree: providers are declared once and read through ref, so no runtime provider-not-found errors.
Composition: a provider can watch other providers and recompute when they change.
Lifecycle: autoDispose frees state when no one listens; family passes a parameter like an id.
watch vs read: watch in build to rebuild on change; read in callbacks for a one-time value.
"Provider depends on the widget tree. If I ask for something that isn't above me, I find out at runtime with an exception, and having two providers of the same type is awkward. Riverpod declares providers as top-level objects and stores their state in a ProviderScope at the root, so reading one is always valid and it doesn't need a BuildContext. Providers can watch other providers, so a filtered list provider can watch the list and the filter and recompute when either changes. autoDispose throws the state away when nothing is listening, which stops stale screens holding memory, and family lets me create one provider per parameter, like a product by id. In widgets, ref.watch subscribes and rebuilds when the value changes, so it belongs in build. ref.read just grabs the current value once, so I use it in callbacks like onPressed. Testing is easier too, since I can override any provider with a fake."
Saying Riverpod keeps state in global variables, or using ref.read in build and wondering why the widget does not update.
Why: the concrete pain with the old approach, such as bugs, untestable code or tangled rebuilds.
Plan: migrate feature by feature behind a clear boundary, old and new side by side.
Safety net: tests written before moving each feature, careful release checks.
Outcome: what improved and what you would do differently.
"In my last role we had an app where most shared state lived in a few huge ChangeNotifiers, and any change to the cart rebuilt half the app and caused odd bugs. The team agreed to move to Bloc, but we couldn't freeze features for a rewrite. So I proposed going one feature at a time. For each feature I first wrote widget tests around the current behaviour, then added a repository layer so data access sat behind one interface, then built the new bloc on top and switched the screens over. Old notifiers and new blocs lived side by side for a few releases, and we only deleted a notifier when nothing referenced it. We started with the cart, which was the most painful, so the team saw the benefit early. Bugs in that area dropped and tests got much easier to write. Looking back, I'd write the conventions doc before the first migration, not after the second."
A story about rewriting everything at once with no tests and no plan for the in-between period.
Understand first: ask what problem they want to solve, perhaps scattered state or hard debugging.
Split the state: local UI state stays close to the widget; shared app state goes in the store.
Costs: needless rebuilds, coupling between features, noisy code for tiny changes.
Agree: a written rule on what goes where, and try it on one feature.
"I'd start by asking what's bothering them, because there's usually a real problem underneath, like state spread across too many places or bugs that are hard to trace. Then I'd separate two kinds of state. Things like whether a password field is obscured or where an animation is are local and short-lived, and they belong in the widget with setState or a controller. Things like the signed-in user, the cart or loaded data are shared, and a store is great for those. Putting everything in one global store means a keystroke in a form can trigger listeners across the app, features become coupled to one giant object, and every tiny change needs its own event and handler. I'd suggest we write down a simple rule for what goes where, try it on one feature, and review it together. That way it's a team decision, not me overruling them."
Dismissing the idea without asking what problem it solves, or agreeing without mentioning any cost.
Future: one value or one error, later.
Stream: many values over time, then maybe done; single-subscription by default, broadcast if many listeners.
In the UI: FutureBuilder and StreamBuilder, always handling waiting, error and data.
"A Future is a single result that arrives later, like the response to one HTTP request. It completes once, with a value or an error, and I usually consume it with async and await. A Stream is a sequence of values over time, like location updates, chat messages or a live database query that keeps pushing changes. I listen to it, and it can emit many values and errors before it closes. Most streams are single-subscription, so listening twice throws; if several places need it, I use a broadcast stream. In the UI, FutureBuilder shows a future and StreamBuilder shows a stream. Both give me a snapshot, and I always handle three cases: still waiting, an error, and data. The most common mistake I see is only handling the data case, so the user stares at a blank screen when something fails."
Saying a Stream is just a Future that repeats, or ignoring error and loading states in the builders.
Cause: the future is created inside build, so every rebuild makes a new request.
Fix: create the future once in initState and store it in a field.
Changes: create a new one in didUpdateWidget if the id changes, or on retry.
Bigger apps: move loading into a state management layer that caches results.
"The usual cause is writing future: api.fetchUser(id) directly inside build. Build can run many times, when a parent rebuilds, when the keyboard opens and the screen size changes, when the theme changes. Each time, calling fetchUser fires a brand new request, and FutureBuilder sees a different future and drops back to the waiting state. The fix is to create the future once, in initState, store it in a field and pass that field to FutureBuilder. If the user id can change, I compare in didUpdateWidget and create a new future only then. For a retry button, I assign a new future inside setState. In a larger app I'd rather move this into Riverpod or a bloc that caches the result, so the widget just renders state and the rule 'build has no side effects' holds everywhere."
class _ProfileScreenState extends State<ProfileScreen> {
late Future<User> _userFuture;
@override
void initState() {
super.initState();
_userFuture = api.fetchUser(widget.userId);
}
void _retry() => setState(() {
_userFuture = api.fetchUser(widget.userId);
});
@override
Widget build(BuildContext context) {
return FutureBuilder<User>(
future: _userFuture,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) return const CircularProgressIndicator();
if (snapshot.hasError) return TextButton(onPressed: _retry, child: const Text('Retry'));
return Text(snapshot.data!.name);
},
);
}
}
Fixing it by making the widget stateless or adding a flag, without understanding that build can run many times.
Event loop: async code yields while waiting, so I/O like network calls does not block frames.
What still freezes: long synchronous work such as parsing a huge JSON string or processing an image.
Isolates: separate memory and their own event loop; they talk by passing messages, not by sharing objects.
Tools: Isolate.run or compute for one-off jobs; a long-lived isolate with ports for a stream of work.
"Dart's main isolate runs an event loop. When I await a network call, the function pauses and the loop keeps drawing frames, so waiting on I/O doesn't freeze anything. What does freeze it is heavy synchronous work. If I decode a multi-megabyte JSON response or resize an image on the main isolate, that code runs start to finish without yielding, and the UI misses frames. Marking the function async doesn't help, because the work itself is still synchronous. That's when I use an isolate. An isolate has its own memory and its own event loop, so nothing is shared. I send it input and get a result back as messages, and sending very large inputs has its own cost. For a one-off job I use Isolate.run or compute. If I need a worker that handles many jobs, I spawn a long-lived isolate and talk to it through ports."
import 'dart:convert';
import 'dart:isolate';
Future<List<Product>> parseProducts(String body) {
return Isolate.run(() {
final list = jsonDecode(body) as List<dynamic>;
return list
.map((e) => Product.fromJson(e as Map<String, dynamic>))
.toList();
});
}
Saying that marking a function async moves its work to a background thread.
The rule: a parent gives each child min and max width and height; the child picks a size inside them; the parent places it.
The Column: it lets non-flexible children be as tall as they like in its main axis.
The ListView: it wants to fill all the height it is given, and infinity is not a size.
Fixes: Expanded or Flexible, a fixed-height box, or shrinkWrap for short lists only.
"Layout in Flutter is one pass down and one pass up. Each parent passes constraints to its children, a minimum and maximum width and height. Each child picks a size within those limits and reports it back, and then the parent decides where to put it. A child can't just choose any size it likes; it only chooses within what it was given. Now a Column: for children that aren't wrapped in Expanded or Flexible, it doesn't limit height, because it wants to know how tall they naturally are. A ListView is a scrollable viewport that tries to be as tall as allowed. Given unlimited height, it can't pick a size, so Flutter throws the unbounded height error. The usual fix is wrapping the ListView in Expanded so it gets the leftover space. A SizedBox with a set height works too. shrinkWrap also works, but it lays out every item, so I only use it for short lists."
Reaching for shrinkWrap everywhere without knowing it lays out every child and hurts long lists.
Shared idea: both take a share of the space left after fixed-size children, split by their flex values.
Expanded: a tight fit; the child must fill its whole share.
Flexible: a loose fit by default; the child can be smaller than its share.
Where: only as direct children of a Row, Column or Flex.
"Both are for children of a Row, Column or Flex. The layout first sizes the children that aren't flexible, then splits the leftover space between the flexible ones according to their flex value, so flex 2 gets twice the share of flex 1. The difference is what the child does with its share. Expanded forces the child to fill it completely; under the hood it's just Flexible with a tight fit. Flexible, by default, uses a loose fit, so the child can be as small as it wants, up to its share. So if I have a label and a button in a row and I want the label to take all the remaining width and push the button to the edge, I use Expanded. If I want a long text to shrink and ellipsize when space is tight, but stay its natural width when there's room, Flexible is the better fit."
Saying they are the same, or using Expanded outside a Row, Column or Flex.
Stack: push adds a route on top; pop removes it and shows the one below.
Result: push returns a Future that completes with whatever the next screen passes to pop.
Safety: the result can be null if the user just goes back; check mounted before using context.
"The Navigator keeps a stack of routes. When I call push with a MaterialPageRoute, the new screen goes on top, and pop takes it off and reveals the previous one. The nice part is that push returns a Future. So if I open a picker screen, I can await that push, and when the picker calls pop with a value, that value comes back as the result. I always treat the result as nullable, because the user might just press back or swipe away, and then it comes back null. Since there's an await in between, I also check that the widget is still mounted before calling setState or using context. For passing data forward, I just pass it in the new screen's constructor, which keeps it typed and simple."
// On the first screen
Future<void> _pickColor() async {
final picked = await Navigator.of(context).push<String>(
MaterialPageRoute(builder: (_) => const ColorPickerScreen()),
);
if (!mounted || picked == null) return;
setState(() => _color = picked);
}
// Inside ColorPickerScreen, when the user taps a colour
Navigator.of(context).pop('blue');
Passing results back through global variables, or assuming the result is never null.
Problem: push calls make the stack hard to rebuild from a URL or a notification link.
Declarative idea: the page stack is worked out from app state or the URL.
Wins: deep links, correct browser URLs and back button, auth redirects in one place, nested navigation for tabs.
Cost: more setup; a small app with no deep links may not need it.
"Push and pop are fine until the app has to open straight to a screen from outside. A push notification says 'open order 42', or on the web the user pastes a URL or presses the browser back button. With only imperative pushes, rebuilding the right stack for that is messy. The Router API, sometimes called Navigator 2.0, flips it around: the list of pages is worked out from state, so the URL or app state decides what's on screen. It's powerful but verbose, so most teams use a package like go_router on top. There I declare routes with paths like /orders/:id, the package parses deep links for me, and a redirect function handles things like sending signed-out users to login in one place. It also supports shell routes for a bottom navigation bar that stays on screen, and the stateful version keeps a separate stack per tab. For a small app with no deep links, I'd honestly keep plain push."
Not being able to name a single concrete problem, like deep links or auth redirects, that declarative routing solves.
MethodChannel: a named channel; Dart calls invokeMethod, native code registers a handler with the same name.
Messages: async and encoded by a codec, so only simple types like numbers, strings, lists and maps cross.
Errors: native errors arrive as PlatformException; no handler gives MissingPluginException.
Variants: EventChannel for streams of events, Pigeon for generated type-safe code, FFI for C libraries.
"A platform channel is a named, async message pipe between Dart and the host platform. On the Dart side I create a MethodChannel with a unique name and call invokeMethod with a method name and arguments. On Android, in Kotlin, and on iOS, in Swift, I register a handler on a channel with the same name, switch on the method name, do the work and send back a result or an error. Everything is serialized by a codec, so I stick to simple types like strings, numbers, lists and maps. Because it's async, the Dart call returns a Future. A native error comes back as a PlatformException, and if nothing is registered on the other side I get a MissingPluginException, which usually means a wrong channel name or the native code wasn't rebuilt. For continuous data like sensor readings I use an EventChannel. On bigger integrations I prefer Pigeon, which generates typed code for both sides so method names can't drift."
const _channel = MethodChannel('com.example.app/battery');
Future<int?> batteryLevel() async {
try {
return await _channel.invokeMethod<int>('getBatteryLevel');
} on PlatformException catch (e) {
debugPrint('Native call failed: ${e.message}');
return null;
}
}
Thinking Dart objects can be passed directly to native code, or never handling PlatformException.
Hot reload: injects changed Dart code into the running app and rebuilds the tree; state is kept.
Hot restart: restarts the Dart app from main; state is lost; faster than a full rebuild.
Missed by hot reload: code in main or initState that already ran, changed global or static initializers, some type changes.
Neither: native code, new plugins and some config need a full stop and rebuild.
"Both only work in debug mode, because that build runs Dart with a JIT compiler that can accept new code. Hot reload sends the changed code into the running app, and the framework rebuilds the widget tree while keeping state, so if I'm five screens deep with a form half filled in, I stay there and see my UI change. Hot restart throws the Dart state away and runs main again, so the app starts from the first screen, but it's still much faster than a full build. Hot reload won't show changes to code that already ran and doesn't run again, like main or initState of a widget that's already on screen. Changed initial values of global or static variables also aren't applied. Some structural changes, like turning a StatelessWidget into a StatefulWidget, often need a restart too. And changes to native Kotlin or Swift code, or adding a plugin, need a full stop and rebuild."
Thinking hot reload also reloads native code or works in release mode.
Symptom: what broke, on which platform or OS version, and how you reproduced it.
Narrow it down: Dart or native side, which plugin, which device settings.
Tools: native logs, Xcode or Android Studio, a small reproduction project.
Fix and prevention: the change and how you stopped it recurring.
"On an app I worked on at my last company, photo uploads worked on Android but silently failed on some iPhones. The Dart code was the same, so I suspected the native side. I ran the app from Xcode to see the native console, and saw the image picker returning a file in a format our backend rejected, because those phones saved photos in a high-efficiency format by default. On the Android phones we tested, the same flow gave us a JPEG. I confirmed it by logging the file extension and size on both devices. The fix was converting every image to JPEG on the Dart side before upload, so we never depended on the platform default. I added a check that rejected unsupported types with a clear message instead of failing silently, and from then on we tested media features on both platforms before every release."
Only ever looking at Dart logs and never opening the native tooling.
Check first: look for existing plugins, even unmaintained ones worth forking, and read the SDK docs for both platforms.
Design small: a narrow Dart API over the calls the feature needs, not the whole SDK.
Build: a local plugin with platform channels or Pigeon; EventChannel for callbacks; platform views if the SDK shows its own UI.
Expectations: estimate both platforms separately, plan device testing and ownership of upgrades.
"I'd first make sure there really is nothing usable, including older plugins we could fork, and I'd read the SDK docs for both Android and iOS, because the two versions often behave differently. Then I'd design a small Dart API around just what this feature needs, maybe three or four calls, rather than wrapping the entire SDK. I'd build it as a local plugin inside our repo, using Pigeon so the Dart, Kotlin and Swift sides share typed method signatures, and an EventChannel if the SDK pushes callbacks like status updates. If the SDK draws its own screens, I'd look at platform views, knowing they have a performance cost. With product, I'd be honest that this is really two native integrations plus a Dart layer, estimate each platform separately, and flag that someone must own SDK upgrades. A quick spike on one platform first would tell us how rough the estimate is."
Promising a quick estimate without checking both platforms, or wrapping the whole SDK instead of what the feature needs.
Compile time: a const widget is created once and the same instance is reused.
Rebuild skip: when a parent rebuilds and hands over the identical instance, Flutter skips rebuilding that subtree.
Less garbage: fewer objects allocated per frame.
Limits: a const widget still rebuilds on its own setState or when an inherited dependency changes.
"A const constructor lets Dart build the object at compile time, and every use of the same const expression gives you the exact same instance. That matters during rebuilds. When a parent rebuilds, the element compares the new child widget with the old one, and if it's literally the same instance, Flutter knows nothing changed and skips rebuilding that whole subtree. So wrapping a static header or an icon in const means a parent's setState doesn't touch it. It also means fewer objects created every frame, so less work for the garbage collector. It isn't magic though. A const widget can still rebuild if it has its own state that changes, or if it depends on something like Theme.of and the theme changes. And it only works when all the arguments are constant, so anything built from runtime data can't be const."
Claiming const makes a widget never rebuild, or that it is only about code style.
Measure right: profile mode on a real mid-range device, never debug mode or an emulator.
Find the thread: DevTools shows whether frames are slow on the UI thread or the raster thread.
UI thread fixes: build items lazily with ListView.builder, keep item build cheap, no parsing or sorting in build.
Raster fixes: avoid costly effects like Opacity and heavy clips, decode images at display size.
"First I reproduce it properly: a profile build on a real mid-range phone, because debug mode is much slower and would send me after the wrong problem. Then I open DevTools and look at the frame chart. At sixty frames a second each frame has about sixteen milliseconds, and the chart tells me whether the UI thread, which runs my build and layout, or the raster thread, which draws, is over budget. If it's the UI thread, I check that the list uses ListView.builder so only visible items are built, that item widgets don't format dates or sort data in build, and that there's no shrinkWrap forcing every item to lay out. If it's the raster thread, the usual suspects are Opacity widgets, heavy clipping and shadows, and large images decoded at full size, which I fix with cacheWidth or properly sized thumbnails. Then I measure again to confirm the fix actually helped."
Guessing at fixes in debug mode on an emulator without looking at a profiler.
Situation: the screen, the symptom and who noticed it.
Measure: profile build, device used, what DevTools showed.
Fix: the specific cause and the change you made.
Result: how you checked it improved, and what you changed in the team's habits.
"At my last company, our product feed felt choppy on cheaper Android phones, and support kept hearing about it. I ran a profile build on one of those phones and opened DevTools. The raster thread was fine, but the UI thread was blowing the frame budget whenever new items scrolled in. Digging into the timeline, each card was formatting prices and dates and filtering a list of tags inside build, and the whole card rebuilt whenever a like counter changed anywhere on screen. I moved the formatting into the model when the data loaded, split the like button into its own small widget listening only to its own state, and made the static parts const. Then I measured again on the same phone, and the slow frames mostly disappeared. The lasting change was a team rule: no data work inside build, and we check new screens in profile mode on a low-end device."
A story with no measurement, where the fix was a guess and nobody checked it worked.
Default: types cannot hold null unless marked with a question mark.
Sound: the compiler guarantees a non-nullable variable is never null at runtime.
Bang: ! tells the compiler a value is not null; it throws if you are wrong.
late: a non-nullable variable set after declaration; reading it before that throws.
"In Dart, types are non-nullable by default. A String can never be null. If a value might be missing, I write String with a question mark, and then the compiler makes me handle null before I use it. It's called sound because the compiler can guarantee that a non-nullable variable really never holds null at runtime. The exclamation mark is me telling the compiler 'trust me, this isn't null'. If I'm wrong, it throws at runtime, so I use it rarely and prefer a null check, the ?. operator, or ?? to give a default. After I check a local variable for null, flow analysis promotes it to the non-nullable type automatically. The late keyword is for non-nullable variables I can't set at declaration, like a controller created in initState. Reading a late variable before it's set throws an error, so it moves the check to runtime and I only use it when the order of setup is clear."
Adding ! wherever the compiler complains, turning compile-time errors back into runtime crashes.
Layers: unit tests for logic, widget tests for one screen or widget, integration tests on a real device for full flows.
Pump: pumpWidget builds the widget; after an action, pump triggers the next frame.
Find and check: finders like find.text or find.byKey, matchers like findsOneWidget.
Dependencies: inject fakes for APIs so tests are fast and repeatable.
"Flutter has three layers of tests. Unit tests check plain Dart logic, widget tests render a widget in a fake environment without a device, and integration tests run the real app on a device or emulator. Widget tests are the sweet spot, because they're fast and still exercise the UI. In a test, pumpWidget builds my widget, usually wrapped in a MaterialApp so it has a theme and directionality. I find the button, tap it, and then call pump, because tapping only schedules the rebuild and nothing changes on screen until the next frame is pumped. If there are animations, I use pumpAndSettle to let them finish. Then I assert on finders, like expecting to find exactly one widget with the text 1. For screens that call an API, I pass in a fake repository or override the provider, so the test never hits the network."
testWidgets('tapping plus increments the counter', (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);
expect(find.text('0'), findsNothing);
});
Forgetting to pump after the tap and concluding the widget is broken, or testing screens against the real network.
Debug: JIT compiled, asserts on, hot reload, debugger; slow and not for judging speed.
Profile: compiled ahead of time like release, but keeps what is needed for performance tracing.
Release: compiled ahead of time, asserts off, no debugging, smallest and fastest.
Before shipping: test the real release build, signing, version numbers, permissions, obfuscation with symbols kept, crash reporting.
"Debug builds run Dart through a JIT compiler with assertions turned on, which is what gives me hot reload and the debugger, but it's much slower than what users get. Profile mode is compiled ahead of time like release, but keeps enough hooks for DevTools to trace performance, so that's where I measure speed. Release is compiled ahead of time with asserts removed and debugging stripped, so it's fast and small. Before shipping, I install the actual release build on real Android and iOS devices, because some bugs only show up there. I check signing is set up properly, bump the version name and build number, and confirm the permissions and platform config are right for release. If we obfuscate the Dart code, I keep the split debug info files, otherwise crash stack traces become unreadable. And I make sure crash reporting is wired in so we hear about problems before the reviews do."
Only ever testing in debug mode and assuming release behaves the same.
Reproduce: run a release build on a real device and capture native logs.
Read the trace: symbolize obfuscated stack traces with the saved debug symbols.
Usual suspects: logic inside asserts, native code shrinking removing classes a plugin needs, missing config for release, differences in keys or environment.
Communicate: tell the team early, and weigh fixing against rolling back the change.
"First I'd tell the lead straight away, because a launch date is at risk and they need to know early. Then I'd reproduce it with a release build on a real device while watching the native logs, Logcat on Android or the Xcode console on iOS, since a startup crash can happen before any Dart error handler runs. If the code is obfuscated, I'd use the saved symbol files to make the stack trace readable. Then I'd check the usual release-only causes. Code inside an assert doesn't run in release, so any setup hidden there is skipped. On Android, code shrinking can remove classes a native SDK loads by reflection. Release can also use different config, like missing keys or a different environment file. I'd look at what changed since the last good release build to narrow it quickly. If the fix is small and clear, ship it with a full retest. If not, I'd roll back the change that caused it and protect the date."
Changing things at random until it works, or hiding the problem from the team until the last moment.
ClapAssist is an AI interview assistant for Mac and Windows. It listens to the interview on your computer and shows you what to say, in short lines you can read while you talk. Your resume and notes are never stored on our servers. It stays out of screen share on every plan; only you can see it.