İçeriğe geç / Skip to content / Zum Inhalt

Flutter: Real-Time Data Streams with StreamBuilder

Ahmet Balaman

Last updated:

9 min read

FlutterStreamBuilderStreamStreamControllerAsyncRealtime
Flutter: Real-Time Data Streams with StreamBuilder

StreamBuilder connects a source that produces many values over time (chat messages, an order status, sensor readings, a Firestore query) to the widget tree. It subscribes to the Stream you hand it, calls builder again for every new value and takes the State plus setState bookkeeping off your hands. For a single result FutureBuilder is enough; the moment you say "the screen should update as data arrives", this is the tool. Besides the basics, this post focuses on the three things people trip over most: creating the stream inside build, listening to the same stream twice and forgetting to close the StreamController.

Stream vs Future

On a timeline a Future emits one value and finishes, while a Stream emits many values and build runs for each one

Aspect Future Stream
Number of values One Zero, one or many
When it ends When the value arrives When the source closes (done)
Example HTTP request, file read WebSocket, Firestore snapshots(), timer
Widget FutureBuilder StreamBuilder

A Future is a promise that a result will come; a Stream is an open channel: values arrive, an error may show up in between, and eventually the channel closes, or it never does. If async/await and Future are not solid yet, read error handling and async in Dart first; streams build on that foundation.

Live Demo

You can try this widget in the interactive example below:

💡 If the example above doesn't load, click DartPad to run it in a new tab.

Basic Usage

Let's bind a stream that emits a number every second to the screen. The stream is created once in State; no matter how many times build runs, the same object is used:

class CounterStreamPage extends StatefulWidget {
  const CounterStreamPage({super.key});

  @override
  State<CounterStreamPage> createState() => _CounterStreamPageState();
}

class _CounterStreamPageState extends State<CounterStreamPage> {
  // The stream is created once; every build call reuses the same object
  late final Stream<int> _counter = _createCounter();

  Stream<int> _createCounter() async* {
    for (var i = 1; i <= 10; i++) {
      await Future.delayed(const Duration(seconds: 1));
      yield i; // emit a value into the stream
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Counter Stream')),
      body: Center(
        child: StreamBuilder<int>(
          stream: _counter,
          builder: (context, snapshot) {
            if (snapshot.hasError) {
              return Text('Error: ${snapshot.error}');
            }
            switch (snapshot.connectionState) {
              case ConnectionState.none:
              case ConnectionState.waiting:
                return const Text('Starting...');
              case ConnectionState.active:
                return Text(
                  '${snapshot.data}',
                  style: const TextStyle(fontSize: 72, fontWeight: FontWeight.bold),
                );
              case ConnectionState.done:
                return Text('Finished, last value: ${snapshot.data}');
            }
          },
        ),
      ),
    );
  }
}

A function written with async* returns a Stream when called; its body only runs once someone listens, and each yield emits a value. When the loop ends the stream closes by itself and the snapshot moves to done.

Key Properties

Property Description
stream The Stream to listen to; null puts the snapshot in state none
builder Called for every new value, error and state change
initialData Shown as snapshot.data until the first value arrives

Reading connectionState for a Stream

With FutureBuilder, waiting and done were enough; for a stream all four states mean something:

State Meaning
none stream is null, no subscription
waiting Subscribed, but no value has arrived yet
active At least one value (or error) arrived, the channel is still open
done The source closed; the last data or error stays in the snapshot

Two details matter. First, done does not wipe the last value; if you want "the broadcast ended but the final state stays on screen", keep reading snapshot.data. Second, when you pass a different object to stream, StreamBuilder cancels the old subscription, subscribes to the new one and keeps the previous data while doing so, so the screen does not go blank during a source switch.

initialData: Filling the First Frame

Streams often produce their first value only after a while. If you do not want a spinner in that gap, pass initialData:

StreamBuilder<int>(
  stream: _counter,
  initialData: 0,
  builder: (context, snapshot) {
    // snapshot.data is 0 from the first frame, then the incoming values
    return Text('${snapshot.data}');
  },
)

Careful: with initialData set, hasData is true from the very first frame. Detect the "loading" state with connectionState == ConnectionState.waiting, not with !snapshot.hasData.

Producing Your Own Stream with StreamController

When you produce the data yourself (a button tap, a socket message, a timer) you use a StreamController. The example below keeps a message list; on every addition it emits a fresh copy of the list, and StreamBuilder renders it:

import 'dart:async';
import 'package:flutter/material.dart';

class MessageStreamPage extends StatefulWidget {
  const MessageStreamPage({super.key});

  @override
  State<MessageStreamPage> createState() => _MessageStreamPageState();
}

class _MessageStreamPageState extends State<MessageStreamPage> {
  final _controller = StreamController<List<String>>();
  final List<String> _messages = [];

  void _addMessage(String text) {
    _messages.add(text);
    _controller.add(List.unmodifiable(_messages)); // emit the new value
  }

  @override
  void dispose() {
    _controller.close(); // prevents dangling subscriptions and memory leaks
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Message Stream')),
      body: Column(
        children: [
          Wrap(
            spacing: 8,
            children: [
              FilledButton(onPressed: () => _addMessage('Hello!'), child: const Text('Hello')),
              FilledButton(onPressed: () => _addMessage('How are you?'), child: const Text('How are you?')),
            ],
          ),
          Expanded(
            child: StreamBuilder<List<String>>(
              stream: _controller.stream,
              initialData: const [],
              builder: (context, snapshot) {
                final messages = snapshot.data ?? const [];
                if (messages.isEmpty) {
                  return const Center(child: Text('No messages yet'));
                }
                return ListView.builder(
                  itemCount: messages.length,
                  itemBuilder: (context, index) => ListTile(
                    leading: const Icon(Icons.message),
                    title: Text(messages[index]),
                  ),
                );
              },
            ),
          ),
        ],
      ),
    );
  }
}

The builder only renders the incoming value; it never appends to the list itself. That distinction matters: Flutter may call builder as often as it likes, and if you mutate a list inside it the same message gets added several times.

Broadcast vs Single-Subscription Streams

The stream produced by StreamController() is a single-subscription stream: it accepts exactly one listener, and a second one throws. Streams returned by async* functions behave the same way. When several widgets need the same source, use broadcast:

// Single listener: a second listen() call throws
final single = StreamController<int>();

// Multiple listeners: as many StreamBuilders and listen() calls as you like
final shared = StreamController<int>.broadcast();

The price of a broadcast stream: values emitted while nobody is listening are lost, and a late subscriber never sees earlier values. If you need a source that "remembers the last value", keep it in a field as well and pass it as initialData; the scenario below does exactly that. If all you have is a single-subscription stream, asBroadcastStream() makes it shareable, but the cleaner fix is usually to move a single StreamBuilder higher up the tree.

Stream Transformations

Before listening, you can shape a stream with methods such as map, where and take; each returns a new stream:

final ticks = Stream.periodic(const Duration(seconds: 1), (count) => count).take(10);

final evenOnly = ticks.where((n) => n.isEven);
final labels = ticks.map((n) => 'Second: $n');

Do the transformation outside build too; writing stream: ticks.map(...) creates a new stream on every build and walks straight into the first mistake below.

StreamBuilder or listen() in initState?

Both listen to the same stream; the difference is intent. StreamBuilder turns the incoming value into UI and manages the subscription itself (subscribe, resubscribe when the stream changes, cancel when the widget dies). listen() is for triggering a side effect when a value arrives: showing a SnackBar, navigating, writing to a local database. Side effects do not belong in builder, because builder runs during painting and calling Navigator.push or showSnackBar there causes errors.

late final StreamSubscription<OrderStatus> _sub;

@override
void initState() {
  super.initState();
  _sub = tracker.status.listen((status) {
    if (!mounted) return;
    if (status == OrderStatus.delivered) {
      ScaffoldMessenger.of(context).showSnackBar(
        const SnackBar(content: Text('Your order has been delivered')),
      );
    }
  });
}

@override
void dispose() {
  _sub.cancel(); // release the subscription
  super.dispose();
}

If you also want to push the value onto the screen from listen(), you call setState, and then the mounted check and the cancel are your responsibility. Why the order inside dispose matters is covered in the StatefulWidget lifecycle post.

When to Use It and When Not To

Pick StreamBuilder where a single screen directly mirrors a live source: a chat list, an order status, a counter, Firestore snapshots() or WebSocket messages. If the screen goes away together with the source and nobody else uses the data, you need nothing more.

In these cases another tool fits better:

  • The source produces one result (an API request, a file read): wrapping it in a Stream is pointless; use FutureBuilder.
  • Several screens listen to the same stream, or the last value must survive across screens: tying the subscription to one widget's State gets fragile. StreamProvider in Riverpod (or in the provider package) keeps the subscription independent of the screen and gives you the same "loading, error, data" trio.
  • The value only changes inside the same widget (a counter, a selected tab): a Stream is heavy for that; ValueNotifier with ValueListenableBuilder or plain setState is enough.
  • You need to navigate or show a notification in response to a value: that is listen(), not StreamBuilder; see the section above.

Common Mistakes

1. Creating the stream inside build

The classic trap, which the old version of this very post contained:

// WRONG: the getter produces a new stream every time it is read
Stream<int> get counterStream async* { /* ... */ }

// inside build
StreamBuilder<int>(stream: counterStream, ...)

Symptom: the counter restarts on every setState, when the keyboard opens or when the device rotates; the logs show the same job starting over and over. Every time build runs, counterStream returns a new Stream object, StreamBuilder sees "the stream changed", drops the old one, subscribes to the new one and production starts from scratch. The fix is to create the stream once, in a late final field or in initState; the code in the basic usage section does that. The same rule applies to transformations such as stream: controller.stream.map(...).

2. Listening to the same stream in two places

Symptom: the second StreamBuilder or listen() call fails with:

Bad state: Stream has already been listened to.

Cause: the stream is single-subscription. Fix: either use StreamController<T>.broadcast(), or better, listen once with a single StreamBuilder higher up and pass the data down to child widgets as parameters. You can also see this error during hot reload; make sure the stream is created in State, not in build.

3. Not closing the StreamController, or writing after closing

Two symptoms. If you never close it, listeners keep running after the page is gone; a setState inside listen() produces the "setState() called after dispose()" warning and memory leaks. If you call add after closing, you get:

Bad state: Cannot add event after closing

Fix: _controller.close() in dispose, and cancel() for every StreamSubscription. If the code that closes the source can run from more than one place, add an isClosed guard: if (!_controller.isClosed) _controller.add(value).

4. Using data! without checking hasError

Symptom: when the source emits an error the screen crashes with:

Null check operator used on a null value

In a stream an error does not have to end the flow: StreamController.addError leaves the channel open and the next value clears the error. When an error arrives snapshot.data becomes null, so code that writes data! without looking at hasError goes down. Check hasError first, then the data, and give the user a "try again" path. In async* functions a throw closes the stream with an error, followed by done.

Mini Scenario: Live Order Tracking

Picture a food order screen: the status moves through "received, preparing, on the way, delivered". The screen draws the steps with StreamBuilder and shows a SnackBar at delivery through listen(). The source is a broadcast StreamController; the latest status is also stored in a field and passed as initialData, so a late-subscribing widget never sees an empty frame.

import 'dart:async';
import 'package:flutter/material.dart';

enum OrderStatus {
  received('Order received'),
  preparing('Preparing'),
  onTheWay('On the way'),
  delivered('Delivered');

  const OrderStatus(this.label);
  final String label;
}

/// In a real app this would be a WebSocket or Firestore listener.
class OrderTracker {
  final _controller = StreamController<OrderStatus>.broadcast();
  Timer? _timer;
  OrderStatus _current = OrderStatus.received;

  Stream<OrderStatus> get status => _controller.stream;
  OrderStatus get current => _current;

  void start() {
    _timer = Timer.periodic(const Duration(seconds: 3), (timer) {
      _current = OrderStatus.values[_current.index + 1];
      _controller.add(_current);
      if (_current == OrderStatus.delivered) {
        timer.cancel();
        _controller.close(); // flow finished: StreamBuilder moves to done
      }
    });
  }

  void dispose() {
    _timer?.cancel();
    if (!_controller.isClosed) _controller.close();
  }
}

class OrderPage extends StatefulWidget {
  const OrderPage({super.key});

  @override
  State<OrderPage> createState() => _OrderPageState();
}

class _OrderPageState extends State<OrderPage> {
  final _tracker = OrderTracker();
  late final StreamSubscription<OrderStatus> _sub;

  @override
  void initState() {
    super.initState();
    _sub = _tracker.status.listen((status) {
      if (!mounted || status != OrderStatus.delivered) return;
      ScaffoldMessenger.of(context).showSnackBar(
        const SnackBar(content: Text('Enjoy, your order is at the door!')),
      );
    });
    _tracker.start();
  }

  @override
  void dispose() {
    _sub.cancel();
    _tracker.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    final scheme = Theme.of(context).colorScheme;
    return Scaffold(
      appBar: AppBar(title: const Text('Order Tracking')),
      body: StreamBuilder<OrderStatus>(
        stream: _tracker.status,
        initialData: _tracker.current,
        builder: (context, snapshot) {
          if (snapshot.hasError) {
            return Center(child: Text('Connection error: ${snapshot.error}'));
          }
          final status = snapshot.data ?? OrderStatus.received;
          final finished = snapshot.connectionState == ConnectionState.done;
          return ListView(
            children: [
              for (final step in OrderStatus.values)
                ListTile(
                  leading: Icon(
                    step.index <= status.index
                        ? Icons.check_circle
                        : Icons.radio_button_unchecked,
                    color: step.index <= status.index ? scheme.primary : null,
                  ),
                  title: Text(step.label),
                ),
              if (finished)
                const Padding(
                  padding: EdgeInsets.all(16),
                  child: Text('Tracking finished.'),
                ),
            ],
          );
        },
      ),
    );
  }
}

Four decisions live here. The source is broadcast because both StreamBuilder and listen() subscribe to the same stream; with a single-subscription stream the second one would throw. Thanks to initialData: _tracker.current the first frame is not empty; the current step is drawn immediately. At delivery close() is called; StreamBuilder moves to done, but because the last value (delivered) stays in the snapshot all steps remain checked. The order inside dispose is deliberate too: cancel the subscription first, then close the source, then super.dispose().

Frequently Asked Questions

Why does StreamBuilder keep restarting from the beginning?

Because you are passing an object created inside build (a getter, an async* call or a .map(...) transformation) to stream. Every build creates a new stream and StreamBuilder resubscribes to it. Create the stream once in initState or in a late final field.

What does "Stream has already been listened to" mean?

You are trying to listen to a single-subscription stream a second time. If you need several listeners, use StreamController<T>.broadcast(), or listen once with a single StreamBuilder higher up and pass the data down to child widgets.

What is the difference between StreamBuilder and FutureBuilder?

FutureBuilder waits for one result and never changes after done; StreamBuilder listens to a channel, rebuilds on every new value and becomes done when the channel closes. Use the first for a one-off request, the second for live data.

When should I close the StreamController?

When the object that created it dies: in the dispose method if it lives in a State, or in the service's own dispose method if it lives in a service class. If you do not close it, listeners keep running and memory leaks.

Comments