A Future<T> is a value, or an error, that will arrive later. async marks a function that returns a Future, and await pauses that function until the Future completes, then gives you the value or throws its error.
Three states: uncompleted, completed with a value, completed with an error
**try/catch/finally work as usual**: wrap the await and error handling reads like synchronous code
The thread is not blocked: await hands control back to the event loop, so the UI keeps drawing frames while you wait
Common mistake: a try/catch around a call you did not await catches nothing: the error lands in the Future, not on your stack
FlutterJunior
Async & Futures
Future, async/await, then/catchError, FutureBuilder
Questions closed
0 / 22
- 0Review
- 3Asked often
Questions
No. await only lets the event loop do other work while something outside your isolate is pending: a network call, a file read, a timer. Your own Dart code still runs on the same single thread, between the awaits.
Waiting is free, working is not: I/O waits outside the isolate, but a long loop or a jsonDecode of several megabytes holds the thread until it returns
**async does not split work**: the code between two awaits runs as one uninterruptible chunk
Real parallelism needs an isolate: Isolate.run(...) or Flutter's compute(...) runs the work on another thread with its own memory
It prints 1 2 3 4. An async function runs synchronously, on the caller's stack, up to its first await; there it returns an unfinished Future, the caller continues, and the rest of the body runs later.
Calling it is not free: everything before the first await runs right away, so heavy setup there blocks the caller
**After the await: the rest of the body runs once the awaited Future completes, never on the caller's stack
A ready value still suspends**: await null or await Future.value(1) still queues the rest of the function as a microtask
**Errors before the first await**: in an async function they still go into the returned Future, so a try/catch around an un-awaited load() catches nothing
Both build the same Future, so it is a readability choice, and async/await usually wins. .then runs on success, .catchError on failure, and .whenComplete runs in both cases, like finally, without seeing or changing the value.
**await reads top to bottom**: try/catch/finally look like synchronous code, while dependent .then steps nest
Every call returns a new Future: f.catchError(...) does not change f; you have to use the Future it returns
**whenComplete passes the result through: the value or error goes on unchanged, unless its callback throws, and then that error replaces it
Common mistake**: return future; inside a try without await: the function returns before the error arrives, so the catch never runs
Timer(duration, callback) runs a callback once after a delay, and Timer.periodic runs it repeatedly; both return a Timer you stop with cancel(). Forget that in dispose() and the callback keeps firing after the screen is gone.
What breaks: setState() on a disposed State throws "setState() called after dispose()", and the closure keeps the State and everything it references in memory
Debounce: cancel the previous Timer and start a new one on every keystroke
Timing is approximate: callbacks go through the event queue, so a busy isolate delays them; for a countdown, compute the remaining time from a DateTime instead of counting ticks
FutureBuilder rebuilds part of the UI from a Future's state: waiting, done with data, or done with an error. It does not own the Future, so a Future created inside build() is a new request on every rebuild.
Create it once: in initState or a field; replace it in didUpdateWidget when the input, such as an id, changes
Why rebuilds are frequent: a parent setState, the keyboard opening, a theme change; each one restarts the call and flashes the spinner
Read the snapshot - check connectionState == ConnectionState.waiting first: a new Future keeps the previous data and error in the snapshot while it runs. Then hasError, then hasData
Refresh: assign a new Future inside setState; set initialData if you have cached data to show meanwhile
No cancellation: a replaced Future keeps running; FutureBuilder just ignores its result
When to move on: once you need retry, caching or sharing between screens, a state manager (a Cubit, Riverpod's FutureProvider) does it better
Because with void the caller gets nothing to await: it cannot wait for the work to finish, and errors thrown inside skip the caller's try/catch and surface as uncaught errors. Future<void> costs nothing and gives the caller both.
Sequencing: await saveDraft(); close(); only works when saveDraft returns a Future
Tests: a test cannot wait for an async void method, so it asserts too early and turns flaky
Lints: avoid_void_async flags the signature, and unawaited_futures then catches callers that forget the await
The exception: callbacks typed void Function(), like onPressed, accept an async closure, but nobody awaits it, so handle errors inside it
The function carries on before the work finishes, and an error from that Future has no listener, so it becomes an uncaught async error instead of reaching your try/catch. Fire-and-forget is fine only for work whose result nobody needs, with its own error handler.
Order bugs: the next line runs before the save finishes, so the next screen reads stale data
Lost errors: they go to PlatformDispatcher.onError or the current zone, never to the caller
The lint: unawaited_futures flags a Future that is neither awaited nor returned; unawaited(f) from dart:async says "on purpose"
Good candidates: analytics, logging, cache prefetch: unawaited(track(e).catchError(logDrop)), or .ignore() when even the error does not matter
Never: a save, a payment, or anything the user or the next screen depends on
forEach ignores what its callback returns, so the Futures of the async closure are dropped: syncPhotos shows the toast and completes before any upload ends, and a failed upload becomes an uncaught error instead of reaching the caller.
Why it happens: forEach takes a void Function(T); an async closure fits that type, but its Future goes nowhere, so nothing waits for it and no try/catch sees its error
A second effect: all uploads start at once, with no limit, and the markSynced calls finish in any order
The fix: a for-in loop inside the async function awaits each upload in turn, so the toast runs after the last one and the first failure reaches the caller; await Future.forEach(photos, ...) does the same
Parallel on purpose: await Future.wait(photos.map(uploadAndMark)) keeps the concurrency but still waits and still reports errors; bound it for long lists
Catch it next time: the avoid_function_literals_in_foreach_calls lint from the recommended set flags forEach with a function literal; a test that checks the uploads after await syncPhotos(...) fails at once
Start all the Futures first and wait for them together: Future.wait([...]), or in Dart 3 a record, await (fetchUser(), fetchPrefs()).wait. Awaiting them one by one makes the total time the sum of the calls instead of the longest one.
**Future.wait**: results come back in the original order as one List<T>; mixed types collapse to their common supertype, usually List<Object>, and need casts
**Record .wait: each position keeps its own type and destructures straight into variables
On failure**: Future.wait still waits for everything, then throws the first error and drops the successful results; eagerError: true throws at the first error. Nothing is cancelled either way
Keep partial results: record and list .wait throw a ParallelWaitError holding both values and errors; or give each Future its own .catchError fallback
**Future.any: completes with the first Future to settle, and an error can win that race
Watch the count**: hundreds of requests at once can trip a rate limit; bound them with a small pool
Each isolate runs one thread with an event loop and two queues. The loop takes one event, runs it to completion, then drains the entire microtask queue before taking the next event, so microtasks always run first.
Event queue: timers, Future(...), Future.delayed, finished I/O, platform messages and pointer events; Flutter's frame callbacks run on the same thread between them
Microtask queue: scheduleMicrotask, Future.microtask, callbacks on an already-completed Future, including await on a ready value
Run to completion: nothing interrupts running Dart code; a long synchronous chunk delays every event, including the next frame
Starvation: a microtask that keeps scheduling microtasks never lets the event queue run: no input, no timers, no frames
Yielding: await Future<void>.delayed(Duration.zero) puts the rest of a function behind pending events, which lets a long loop give the UI a turn
No parallelism: the loop interleaves tasks; running code at the same time needs another isolate
A F G D E B C. Synchronous code runs first (Future.sync calls its function immediately), then the whole microtask queue (D, E), then the event queue in order (B, C).
**Future(...)**: schedules the function on the event queue, like a zero-length Timer
**Future.delayed(Duration.zero, ...): also a timer event; timers with the same delay fire in the order they were created
scheduleMicrotask / Future.microtask: the microtask queue, which runs before any event
Future.value(x)**: completes in a microtask, so its .then runs right after D
**Future.sync(f)**: runs f now, on the caller's stack, and only wraps its result or thrown error in a Future
What the interviewer checks: that a .then callback never runs synchronously, even on a Future that is already complete
A Completer<T> creates a Future that you complete by hand: you hand out completer.future, then call complete(value) or completeError(error, stackTrace) when the result arrives. Use it to turn a callback-based API into a Future.
Typical uses: wrapping a listener or callback SDK, waiting for a one-off event such as the first frame or a platform reply
Complete exactly once: a second complete throws a StateError; check isCompleted when several callbacks can race
Every path must complete: an error path or a removed listener that never completes it leaves callers waiting forever; add .timeout if the source can go silent
Pass the stack trace: completeError(e, st), or the trace starts at the Completer instead of the real failure
Not around a Future you already have: async/await is shorter and cannot forget to complete
FutureOr<T> is a type that means either a T or a Future<T>. A function returns it when the result is sometimes ready at once and sometimes not, such as a cache hit versus a network fetch, so the fast path skips a trip through the event loop.
**You can await it: awaiting a plain value is legal and costs one microtask
Where you meet it most**: parameters that accept both shapes: the callback of .then, Completer.complete, a FutureOr<T> Function() task passed to a helper
**An async function never takes the fast path: it always returns a Future, even for a value it already has
Use it sparingly in your own API**: callers must handle both shapes and is Future checks get fragile; return Future<T> unless profiling shows the synchronous path matters
load is not async, so the throw happens on the caller's stack before any Future exists. The exception leaves the load(id) expression itself, and .catchError is never even attached.
**.catchError only sees errors a Future carries: a synchronous throw never becomes one
Fix 1**: mark load as async; then every throw in its body completes the returned Future with that error
Fix 2: keep it non-async but wrap the body in Future.sync(...), which turns a synchronous throw into a failed Future
**Future.value(parse(raw)) does not help**: the argument is evaluated, and throws, before Future.value is even called
Rule of thumb: a function that returns a Future should never throw synchronously; async is the simplest guarantee
return api.fetchProfile(id) hands back the Future without awaiting it, so the try block is left before the request fails. The error arrives later, outside the try: the on NetworkException branch never runs and the caller gets the exception instead of the cache.
Why it happens: in an async function, return future exits the try at once; the function's own Future then takes the result of the returned one, error included, with no handler around it
The fix: return await inside the try waits for the result there, so a failure is thrown while the catch is still active
**Outside try it makes no difference**: that is why the unnecessary_await_in_return lint flags such an await elsewhere but leaves it alone inside a try
**Same trap with finally: it runs before the returned Future completes, so cleanup such as hiding a spinner or closing a client happens too early
Catch it next time**: no stock lint flags this line (unnecessary_await_in_return deliberately skips await inside a try), so rely on a unit test with a fake API that throws NetworkException and a code-review rule: inside try, always return await
future.timeout(duration) returns a new Future that fails with a TimeoutException if the original takes too long, or returns a fallback from onTimeout. Retries you write yourself or take from a package such as retry: a loop with a growing delay and a cap.
A timeout cancels nothing: the original request keeps running, you just stop waiting; to stop the work, cancel at the source (Dio's CancelToken, http.Client.close())
Exponential backoff with jitter: 0.5 s, 1 s, 2 s plus a random bit, so thousands of clients do not retry in lockstep
Retry only what can succeed later: timeouts, dropped connections, 5xx, 429 (honour Retry-After); never 400, 401 or 404
Cap the attempts: three to five, then surface the error; unbounded retries turn an outage into a flood you caused
Idempotency: retrying a payment or "create order" can run it twice unless the server accepts an idempotency key
Set two hooks in main(): FlutterError.onError for errors the framework catches in build, layout, paint and gestures, and PlatformDispatcher.instance.onError for uncaught async errors. A try/catch around runApp() only sees synchronous throws during that one call.
Why try/catch fails: runApp returns almost at once; later errors happen in frames, callbacks and Futures long after the try block has exited
**FlutterError.onError**: call FlutterError.presentError(details) to keep the console output, then forward to your crash reporter
**PlatformDispatcher.instance.onError**: return true to mark the error handled; it replaced runZonedGuarded as the recommended setup
**runZonedGuarded**: still works, but ensureInitialized and runApp must run inside the same zone, or Flutter warns about a "Zone mismatch"
**ErrorWidget.builder: replaces the default error box (red in debug, grey in release) with a calm fallback
Not covered**: errors in other isolates need Isolate.addErrorListener or the onError port of Isolate.spawn
You cancel whatever is doing the work, not the Future: a Future is only a handle to a result. If the source cannot be stopped, you let it finish and ignore the result when it arrives.
Cancel at the source: Dio's CancelToken, http.Client.close(), StreamSubscription.cancel(), Timer.cancel(), a message or kill() to a worker isolate
Ignore stale results: check mounted after the await, or keep a request counter and drop answers that are no longer the latest, as in search-as-you-type
**CancelableOperation**: from package:async; cancel() runs your onCancel, where you stop the source, and its value never completes afterwards
Tie it to the lifecycle: dispose(), a Bloc's close(), Riverpod's ref.onDispose
Common mistake: assuming the work stopped with the screen; it keeps using network, battery and memory, and may still write to state
A Future reports its error as unhandled at the moment it completes if no listener is attached yet. Your await a second later attaches to a Future that has already sent the error to PlatformDispatcher.onError, so you see it both in your catch and in the crash log.
The rule: a handler must be attached before the Future can complete, not whenever you get round to awaiting it
The fix: _config = loadConfig()..ignore(); marks the error handled now; a later await _config still throws into your try/catch
Or attach a real handler: loadConfig().catchError(...) with a fallback value, if a sensible default exists
Where it appears: a Future started in a constructor or initState and awaited later in a handler; caches that store a Future for reuse
Why it is flaky: a fast failure completes before the await runs and a slow one after it, so it reproduces only on some networks
Any place that rethrows or wraps an error without its original StackTrace. The VM already stitches async frames together across await, so the trace usually breaks in your own catch blocks, catchError callbacks and Completers.
**rethrow, not throw e**: throw e starts a new trace at the catch block
Wrapping: throw SaveFailed(e) points at the wrapper; Error.throwWithStackTrace(SaveFailed(e), st) keeps the original trace
Two-argument handlers: catch (e, st) and .catchError((Object e, StackTrace st) {...}); the one-argument form never sees the trace, so whatever it wraps or rethrows has none
Completers: completeError(e) without st records a trace where you completed it, not where it failed
Reporters: pass the st you were given, never StackTrace.current read inside the catch
Obfuscated release builds: with --obfuscate --split-debug-info, traces stay unreadable until you upload the symbol files to the crash reporter
Every await is a gap where other code runs, so two calls that read, await, then write can interleave even on one thread. The fix is to make the second caller wait for, or share, the first call's work instead of starting its own.
Single-flight: store the in-flight Future; later callers get the same one, and the field is cleared when it finishes. The classic case is a token refresh after a burst of 401s
Lock: chain each operation onto the previous one so they run strictly one at a time; Lock from package:synchronized does exactly this
Queue: one consumer that processes events in order, such as stream.asyncMap(handle), for writes that must keep their order
Push it down: let storage do it atomically: a SQL transaction, or UPDATE ... WHERE rev = ?
Common mistake: a bool _busy flag checked before an await and set after it; both callers pass the check
Across isolates: none of this helps there; isolates share no memory, so route the work through one owner isolate or the database