<FlutterSolution/> flutter · dart · clean architecture

Flutter Interview Prep #12 Streams, Futures & the Dart Event Loop: What async and async* Are Actually Doing

Streams, Futures & the Dart Event Loop: What async and async* Are Actually Doing

A deep dive into Dart's asynchronous execution model, queue exhaustion, and the difference between producing a result and producing a sequence.

As you move from intermediate to senior Flutter roles, your technical interviews shift from "how do you use this widget?" to "explain what the runtime is doing underneath this code." One of the most heavily scrutinized areas is asynchronous programming.

Every developer knows how to type await to wait for an API call. But when you are asked to debug a frozen UI that hasn't thrown an exception, or explain why a particular StreamSubscription caused an Out Of Memory crash, "just use await" isn't enough. You need to understand the underlying event loop.

Let's map out exactly what Futures, Streams, and generators are actually doing at the mechanism level.

01. The Fundamental Split: Futures vs. Streams

The first decision a developer makes when designing an asynchronous API is choosing the return type. In Dart, this boils down to one question: is this fundamentally a one-shot result, or an ongoing sequence of events?

  • A Future represents a single value (or an error) that will be resolved exactly once. When a Future completes, it's done. It can never emit a second value.
  • A Stream represents zero or more values (or errors) delivered sequentially over time.

Future: One-Shot

Like asking for the final downloaded file.

Stream: Sequence

Like observing file download progress chunks.

If you are writing a service that fetches a configuration file, use a Future. If you are writing a service that exposes a live stock price ticker or a file-download progress indicator, use a Stream.

02. Broadcast vs. Single-Subscription Streams

If you've worked with Streams in Flutter, you've almost certainly encountered this crash: Bad state: Stream has already been listened to.

This happens because Dart streams come in two distinct flavors, and they behave very differently regarding memory and event buffering.

Single-Subscription Streams (The Default)
A single-subscription stream allows exactly one listener for its entire lifetime. If a producer starts emitting events before a listener is attached, the stream buffers those events. It assumes that if you created the stream, you care about the data, and dropping it would be a bug. Once a listener attaches, it gets the buffered data. If a second listener tries to attach, it throws an error to prevent memory leaks and duplicated buffering logic.

Broadcast Streams
A broadcast stream allows multiple listeners to attach and detach over time. Crucially, it does not buffer events for latecomers. If you attach a listener three seconds after the stream starts emitting a live price ticker, you simply miss the first three seconds of prices.

dart
// The Mistake: Listening twice to a single-subscription stream
final progressStream = downloadService.getProgress(); 

// Listener 1 attaches successfully
progressStream.listen((percent) => print("Console: $percent%"));

// Listener 2 (maybe a UI widget) attempts to attach -> CRASH!
// Unhandled Exception: Bad state: Stream has already been listened to.
progressStream.listen((percent) => updateUI(percent));

// ==========================================
// The Fix: Use asBroadcastStream() or a Broadcast Controller
final broadcastStream = progressStream.asBroadcastStream();

// Now both can listen simultaneously
broadcastStream.listen((percent) => print("Console: $percent%"));
broadcastStream.listen((percent) => updateUI(percent));

03. The True Anatomy of async / await

When you sit in a senior interview, a common trap question is: "Does an async function run in a separate thread?"

The answer is No. The Dart runtime uses an Isolate model. By default, your Dart code runs on a single thread. The async and await keywords do not magically spawn threads or block the isolate. They are fundamentally syntactic sugar over Future.then() chaining.

Here is what happens precisely when the compiler sees an async function:

  1. An async function always returns a Future. Even if your function signature says Future<int> and your body just says return 5;, Dart automatically wraps that `5` in a Future.
  2. When execution hits an await keyword, the function evaluates the expression on the right (which yields a Future).
  3. It then pauses execution of that function. Control is immediately yielded back to the Dart Event Loop. The isolate is free to go paint the UI, handle touch events, or run other code.
  4. When the awaited Future completes, the rest of the async function is scheduled as a continuation (a microtask), and resumes exactly where it left off.

Senior-Level Sample Answer

"Under the hood, async/await is just a more readable way of composing the same Future-based continuation model that already existed in Dart. When I write await fetchData(), I'm effectively telling the compiler to take all the code below that line, wrap it in a fetchData().then(...) callback, and return control to the event loop. It guarantees sequential execution of the logic without ever blocking the underlying thread."

04. async* and the Power of Lazy Emission

While async functions return a single Future, async* (async generator) functions return a Stream.

Instead of using return to deliver a single value, an async* function uses the yield keyword to emit values one at a time, lazily, as the function executes.

Why use this instead of building a List and wrapping it in Stream.fromIterable()? Because building a List is eager. It requires all data to be generated and held in memory before the stream can begin. An async* function evaluates lazily: it pauses its own execution after every yield until the listener is ready for the next event.

Let's simulate a download progress tracker ticking upward:

dart
// Returns a Stream, not a Future. Uses async* instead of async.
Stream<int> simulateDownloadProgress() async* {
  for (int progress = 0; progress <= 100; progress += 20) {
    // Wait 500ms before generating the next chunk of progress
    await Future.delayed(const Duration(milliseconds: 500));
    
    // Emit the current progress into the stream
    yield progress; 
  }
}

// Consuming the generator using await for
void startDownload() async {
  print('Download starting...');
  
  // 'await for' handles subscribing and pausing on each emission
  await for (final chunk in simulateDownloadProgress()) {
    print('Progress: $chunk%');
  }
  
  print('Download complete!');
}

05. Flattening Streams: yield vs yield*

In an interview, you might be asked to spot the difference between yield and yield* inside an async* function. This is a great test of whether you've deeply used generators or just copied snippets.

  • yield emits a single value into the stream.
  • yield* takes another Stream (or Iterable), delegates execution to it, and re-emits every value from that nested stream into the current one until the nested stream closes. It flattens the output.
dart
Stream<int> getSubProgress() async* {
  yield 1;
  yield 2;
}

Stream<dynamic> masterStream() async* {
  // Emits the Stream OBJECT itself (probably a bug!)
  yield getSubProgress(); 
  
  // Delegates to the Stream, emitting '1', then '2'
  yield* getSubProgress(); 
}

06. The Dart Event Loop: Microtasks vs Events

To truly master Dart, you must understand how the engine prioritizes asynchronous work. Dart maintains two queues: the Microtask Queue and the Event Queue.

  • Event Queue: Handles "external" events—I/O callbacks, timers, mouse clicks, drawing a Flutter frame, or data arriving on a socket.
  • Microtask Queue: Handles "internal" asynchronous continuations—primarily the callbacks inside Future.then() and anything scheduled via scheduleMicrotask().

Here is the unbreakable rule of the Dart event loop: The Microtask Queue must be drained to COMPLETE EXHAUSTION before a single item from the Event Queue is processed.

The Queue Priority Engine

Watch how microtasks drain entirely before the UI event is processed.

Microtask Queue
(Future completions)
Event Queue
(UI Touch, Timers)

⚠️ Common Bug: UI Starvation

Because Microtasks always win, a long chain of Futures that constantly resolve and spawn new Futures will lock up your Flutter app. The Event loop gets stuck endlessly draining the Microtask queue, meaning it never reaches the Event queue to process user inputs or paint the next UI frame. Your app looks frozen, but nothing crashed.

To fix queue starvation, developers sometimes use await Future.delayed(Duration.zero). This looks like a hack, but it has a specific mechanical purpose: a Timer (which Future.delayed uses) runs on the Event Queue. By yielding to it, you intentionally push the rest of your function to the back of the Event Queue, allowing pending UI repaints and touch events to process first.

Note: While this is a legitimate technique to break up heavy synchronous computations, relying on it often indicates a code smell. If you have that much heavy logic, it belongs in a separate Isolate (using compute()), not just delayed on the main thread.

07. Controlling the Flow: Controllers & Transformers

When you need to construct your own custom reactive logic, you'll use three main pieces of the Stream architecture:

  1. StreamController: The producer's tool. It gives you a sink to imperatively push events (like a new price from a websocket) into a stream you expose to the rest of the app. If you need multiple listeners, use StreamController.broadcast().
  2. StreamTransformer: The pipeline tool. It takes an input stream, applies logic (like mapping data, filtering out duplicate events, or debouncing rapid clicks), and outputs a new stream.
  3. StreamSubscription: The listener's handle. When you call listen(), it returns a subscription. You use this to pause(), resume(), or cancel() the flow of events to your callback.

08. The Classic Flutter Memory Leak

If an interviewer asks, "Tell me about a memory leak you've debugged in Flutter," an uncancelled StreamSubscription is an incredibly strong, honest answer.

When a StatefulWidget subscribes to a stream (say, a user profile update stream) in initState, it passes a callback function to the stream. That callback typically references setState or instance variables on the widget's State class. This creates a strong closure reference from the Stream over the State object.

If the user navigates away and the widget is removed from the tree, but you forget to call subscription.cancel() in the dispose() method, the Stream continues to hold that reference. The garbage collector cannot clean up the State object, the widget, or any of its children. The invisible widget keeps receiving events and calling setState() (triggering the infamous "setState() called after dispose()" error), slowly ballooning your app's memory until it crashes.

✅ Quick Self-Check

Before moving on, ensure you can answer this out loud: If a broadcast stream emits an event while a widget is navigating and hasn't called listen() yet, what happens to that event? (Answer: It is lost forever. Broadcast streams do not buffer for late listeners).

09. Tying It Together

Let's connect generators back to the event loop. What happens if you use an await inside an async* function?

It behaves exactly the same way it does in a normal async function. The async* function yields control back to the event loop while waiting for the Future to resolve. The async* designation isn't a separate execution model—it's the exact same microtask and event-queue behavior you already know, simply paired with lazy, incremental output via yield instead of a single eventual return value.

🧠 Interviewer's Follow-Up

If you nail the basics, expect these follow-ups:

  • Q: What is the difference between yield and yield*?
    Pointer: yield emits a single item; yield* flattens and delegates to another stream/iterable.
  • Q: How do you safely listen to a stream in a Flutter widget without managing subscriptions manually?
    Pointer: Use a StreamBuilder, which automatically manages the subscription lifecycle across initState and dispose.

Official Resources

Nachiketa Pandey
// written by

Nachiketa Pandey

Senior Flutter Developer · Cross-Platform & Fintech Applications

Building production Flutter apps at Kotak Neo — clean architecture, BLoC, agentic AI integrations, and fintech-grade security. Sharing everything I learn, one post at a time.

Flutter & Dart Clean Architecture BLoC Firebase FinTech
View Full Portfolio

Comments

Nachiketa
Meet Author// tap to open