When to use Realtime Updates vs One-Time Reads in Your Flutter Apps

Source code on GitHub

In the fast-changing world of mobile apps, presenting data effectively to users can make or break the user experience. Flutter developers, in particular, have to decide how to fetch and display data: should the app rely on one-time reads, or does it need the immediacy of realtime updates?

This decision is critical, as each option has unique implications for user experience and app performance. In this article, we’ll cover:

  • The mechanics and pitfalls of one-time reads
  • What are realtime updates, and where they excel
  • How to build a simple CRUD application with data syncing using Firebase
  • Cost considerations with one-time reads vs realtime updates
  • When should realtime updates be avoided

Join me as we explore the nuances of data delivery in Flutter apps so you can ship better apps, faster. 🔥

What are One-Time Reads and How They Work

Suppose you’re building a Flutter app to display the latest weather in London. When your app needs this data, it reaches out to a server via a traditional REST API:


CLIENT:

  • What’s the weather in London right now?

SERVER:

Here’s the update:

  • Temperature: 10°C
  • Min Temperature: 5°C
  • Max Temperature: 15°C
  • Description: Moderate rain

This interaction is what we call a one-time read. Your app asks once and gets the information just once (using a request-response communication model).

How to Implement One-Time Reads

To implement this in Dart, we can create a method that returns a Future with the response data from the API:

Future<Weather> getWeather(String city) { // use http or dio package to fetch and return the weather // or throw an exception if something goes wrong }

There are many ways to consume this API in Flutter. For example, we could use FutureBuilder, or even call setState inside a StatefulWidget so the UI can rebuild, like in this example:

Weather? _weather; // called from initState Future<void> updateWeather() { // fetch the weather and update the state so the UI can rebuild final weather = await getWeather('London'); setState(() => _weather = weather); }

Or, if we’re using Riverpod, we can wrap the method above inside a FutureProvider:

// build_runner will generate the corresponding fetchWeatherProvider // data caching and auto-disposal are already taken care of 👍 @riverpod Future<Weather> fetchWeather(FetchWeatherRef ref, String city) { return getWeather(city); }

An inside our widget’s build method, we can watch the provider and use .when to map the returned value to the UI:

// inside a ConsumerWidget @override Widget build(BuildContext context, WidgetRef ref) { final weatherAsync = ref.watch(fetchWeatherProvider); return weatherAsync.when( data: (weather) => Text(weather.temp), loading: () => CircularProgressIndicator(), error: (e, st) => Text(e.toString()), ); }

The code above works, but if the weather changes—which it often does—your app won’t have a clue until it asks again.

If the client wants updated data, it needs to ask again
If the client wants updated data, it needs to ask again

Drawbacks of One-Time Reads

Now, what can you do if your weather-enthusiast users want their updates continuously? Here are two options:

  • Pull-to-Refresh: Add a RefreshIndicator widget that lets users tug down on their screen to demand the latest forecast. Each pull is like asking the server, "How about now? And now?" Users get up-to-date data, along with that satisfying buzz of fresh information.
  • Polling: Set a timer within your app that automatically and routinely asks the server for updates. This doesn’t require any effort from your users. The app UI will quietly keep itself current, giving them the latest weather without lifting a finger.

Both pull-to-refresh and polling solve the immediate issue, but they’re far from perfect and come with their own headaches:

  • Overfetching: When the app checks in with the server more often than necessary—imagine your app eagerly asking for updates every minute, while the server only refreshes the forecast every half hour. You’re fetching data that hasn’t changed, wasting bandwidth and resources.
  • Stale data: When the app doesn’t check in often enough. If it’s only asking for updates every hour, yet the server refreshes every five minutes, your users might miss out on important weather shifts.

At the core of the problem is this: the server is the source of truth, and the app is left guessing the right rhythm for asking questions.

Bottom line: if we want to get the latest data as soon as it changes, one-time reads fall short, and we should consider realtime updates instead.

How Realtime Updates Work

Imagine a weather station constantly measuring changes in the environment. What if your app could listen directly to any announcements from that station the moment they’re broadcasted? That's the crux of realtime updates.

In this setup, you no longer need to ask the server continually for the latest weather. Instead, the server will instantly push updates to your app—like a weather alert that pops up the moment it’s issued.

Here’s how it might play out:


SERVER:

Heads up! The temperature just dropped to 7°C, and there’s a chance of snow.

CLIENT UI (right away):

  • Current Temperature: 7°C
  • Weather Alert: Snow on the way!

With realtime updates:

  • There’s no overfetching because updates are only sent when there’s something new.
  • There’s no stale data because your app gets the freshest information the moment it’s available.

Think of it as having a dedicated weather announcer just for your app. Your users will always have the latest update because as soon as the data on the server changes, your app knows about it.

Once a client register for updates, it receives them from the server automatically
Once a client register for updates, it receives them from the server automatically

How To Implement Realtime Updates

What was before a Future now becomes a Stream:

Stream<Weather> getWeather({required String city}) { // use websockets or any other realtime API that returns a Stream }

And in the UI, we no longer await for the weather data, but rather, we listen for updates:

Weather? _weather; // called from initState Future<void> getWeatherUpdates() { // listen to weather updates and update the state so the UI can rebuild // note: StreamSubscription creation and disposal need to be added manually 😞 getWeather('London').listen((weather) { setState(() => _weather = weather); }); }

Or, if we’re using Riverpod, we can write code that is nearly identical to what we had before:

// build_runner will generate the corresponding watchWeatherProvider // data caching and auto-disposal are already taken care of 👍 @riverpod Stream<Weather> watchWeather(WatchWeatherRef ref, String city) { return getWeather(city); } // inside a ConsumerWidget @override Widget build(BuildContext context, WidgetRef ref) { final weatherAsync = ref.watch(watchWeatherProvider); return weatherAsync.when( data: (weather) => Text(weather.temp), loading: () => CircularProgressIndicator(), error: (e,st) => Text(e.toString()), ); }

Indeed, the only difference compared to one-time reads is that we declare and watch a StreamProvider rather than a FutureProvider.

What matters most is this: our widget will rebuild every time there's new data on the server, ensuring the UI always shows the freshest data.

Ideal Scenarios for Realtime Updates

Realtime updates shine in situations where up-to-the-second data is crucial. Here are some scenarios beyond our weather app where this approach is ideal:

  • Chat applications: Users see new messages in realtime or receive a push notification when they’re not active in the app, keeping conversations lively and continuous.
  • Social media apps: Users see the number of likes or comments as they are added, in realtime.
  • Stock trading apps: Traders receive market changes instantly, enabling them to make timely, informed decisions.
  • Smart home devices: Users see the current status and notifications of their devices immediately, enhancing the feeling of control and responsiveness.
  • Live sales dashboard: Authors can see all the latest sales inside an admin dashboard when launching a new product.

In these cases, the information changes frequently and realtime updates are essential for a satisfying user experience.

Another obvious use case for realtime updates is auth state changes, where users are automatically redirected to the logged-in UI as soon as they sign in.

Bonus: Data Syncing Made Simple with Realtime Updates

Realtime updates do more than keep your app current—they simplify data syncing across multiple devices.

This is because multiple clients can register themselves as listeners. As soon as the data changes on the server, every subscribed client gets notified and updates immediately:

Data syncing with realtime updates on two apps side-by-side
Data syncing with realtime updates on two apps side-by-side

In essence, realtime updates are the perfect companion to Flutter’s declarative and reactive paradigms, resulting in a seamless experience both for developers and users, where syncing data across clients feels like part of the framework’s DNA.

But how can we implement an example such as the one above?

Realtime Example: ListView with CRUD Operations

Suppose we are showing a list of counters inside a ListView where we can create/update/delete items from the UI:

ListView CRUD example with realtime updates
ListView CRUD example with realtime updates

This is a classic CRUD example where we can:

  • Create a new item (with the FAB at the bottom)
  • Read all the items (and show them inside a list)
  • Update existing items (by incrementing/decrementing each counter)
  • Delete items (with a swipe to dismiss gesture)

If we use a realtime DB such as Cloud Firestore, the four operations above can be implemented as methods inside a FirestoreRepository class:

class FirestoreRepository { const FirestoreRepository(this._firestore); final FirebaseFirestore _firestore; // Create Future<void> createCounter() { final now = DateTime.now().millisecondsSinceEpoch; final id = now.toString(); return _firestore.doc('counters/$id').set({ 'id': id, 'value': 0, }); } // Update Future<void> updateCounter(Counter counter) => _firestore.doc('counters/${counter.id}').set({ 'id': counter.id, 'value': counter.value, }); // Delete Future<void> deleteCounter(String id) => _firestore.doc('counters/$id').delete(); // Read Query<Counter> countersQuery() => _firestore .collection('counters') .withConverter( fromFirestore: (snapshot, _) => Counter.fromMap(snapshot.data()!), toFirestore: (counter, _) => counter.toMap(), ) .orderBy('id', descending: false); } final firestoreRepositoryProvider = Provider<FirestoreRepository>((ref) { return FirestoreRepository(FirebaseFirestore.instance); });

If we want to show all the items in the list, we can leverage the FirestoreListView widget from the Firebase UI for Firestore package:

class CountersListView extends ConsumerWidget { const CountersListView({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final firestoreRepository = ref.watch(firestoreRepositoryProvider); return FirestoreListView<Counter>( query: firestoreRepository.countersQuery(), // The item builder will be called automatically when there's new data itemBuilder: (BuildContext context, QueryDocumentSnapshot<Counter> doc) { final counter = doc.data(); // Custom widget used to show a counter return CounterListTile( key: Key('counter-${counter.id}'), counter: counter, ); }, ); } }

As a next step, we can plug in the update and delete operations with some callbacks:

CounterListTile( key: Key('counter-${counter.id}'), counter: counter, onDecrement: (counter) => ref .read(firestoreRepositoryProvider) .updateCounter(counter.decrement()), onIncrement: (counter) => ref .read(firestoreRepositoryProvider) .updateCounter(counter.increment()), // Implemented internally with a Dismissible widget onDismissed: (counter) => ref.read(firestoreRepositoryProvider).deleteCounter(counter.id), )

The specific syntax doesn’t matter too much (if you want, here’s the full source code).

What matters is that nowhere in our code have we told our FirestoreListView to reload after performing a mutation, since we can rely on the itemBuilder to be called automatically when the data changes.

When you have a single-page app that doesn’t require syncing, it’s easy to keep track of what data needs to be reloaded and when. But inside complex apps where mutations can happen in one place and the UI should reload somewhere else entirely, realtime updates are a game-changer because you can rely on reactive UI updates with no effort on your part.

This all sounds great, but there’s an important question we need to consider. 👇

Does Your Backend Support Realtime Updates?

Realtime updates require specific backend features that are not universally available:

  • Push Infrastructure: Only backends designed to push data to clients can handle realtime updates, which rules out many traditional setups.
  • BaaS Platforms: Services like Firebase, Supabase, and Appwrite offer built-in support for realtime updates, taking the heavy lifting off your shoulders.
  • Traditional REST API Constraints: These APIs generally don’t support realtime updates since they’re based on request-response models, limiting you if you’re consuming third-party services from your client code.
  • Custom Backend Options: If you’re building your own backend, you can implement realtime updates using WebSockets, allowing continuous two-way communication.

Note that if your backend does not support realtime updates, you may be tempted to “fix” that on the client by manually invalidating your data when a mutation takes place. While this can improve things somewhat, it is more like a band-aid that is error-prone and won’t completely solve the problem.

Ultimately, choosing the right backend is crucial for providing a good experience to your users.

But at what cost?

Cost Considerations with One-Time Reads vs Realtime Updates

Here’s what you need to consider:

  • Charges for Reads and Writes: Databases like Cloud Firestore bill you for each read and write operation. With one-time reads, especially if you’re polling frequently, the costs can add up quickly due to overfetching. You’re charged for each data request, even if the data hasn’t changed.
  • Realtime Update Efficiency: When you switch to realtime updates, you might actually lower costs because you’re no longer overfetching. The Firebase Client SDK uses a smart caching implementation to ensure your app only retrieves data from the server when there's a change to that data. This is particularly handy for collections where only a few documents might update (like a single message in a large chat history); instead of reloading the entire collection, only the changed document is fetched and billed.

Efficient caching can also reduce server load and bandwidth usage. By only syncing the delta—the changes—rather than the entire dataset, it not only cuts down on the data being transferred but also conserves the resources used, both on the server and the client.

However, while realtime updates can be cost-effective in many use cases, they can introduce additional costs in high-traffic apps with frequent data updates, due to the need to maintain open connections.

As a result, it is crucial that client apps stop listening to updates as soon as they’re no longer needed.

When you declare a Riverpod provider with the @riverpod annotation, that provider will be automatically disposed as soon as all its listeners are removed. This is exactly what you want when listening to realtime updates with StreamProvider. For more info on this, read: Riverpod Data Caching and Providers Lifecycle: Full Guide.

When should Realtime Updates be Avoided?

As we have seen, realtime updates deliver a win for users (who get the most recent data without effort) and yourself as a developer (since you don’t have to keep tabs on which data you need to reload and when).

But are there scenarios where they should be avoided?

Here’s a handy list:

  • Full-Text Search: When implementing a search-as-you-type feature, the traditional request-response model is more suitable. Realtime updates are not necessary here, since the search results are only needed for a short time - until users find what they were looking for.
  • Forms and Data Submission: For forms where data is pre-loaded once and submitted upon completion, realtime updates can be overkill and potentially disruptive. One-time reads ensure users can work undisturbed, with the certainty that they’re handling a consistent set of data throughout their editing process.
  • Static Content Display: Content that rarely changes, such as help pages or your app’s T&Cs (😉), doesn’t require realtime updates. One-time read upon access or refresh is sufficient and avoids the overhead of implementing a live data connection.
  • Batch Jobs or Scheduled Tasks: When dealing with operations that need to run at specific times—like nightly data processing or regular maintenance tasks—realtime updates are unnecessary. These tasks often function autonomously, based on a schedule or trigger, with no need for live data interaction. However, note that these tasks are typically done on the backend, and you may still write the results into a realtime DB such as Cloud Firestore (which the client app can listen to).
  • App Configuration Changes: Services like Firebase Remote Config allow you to change the behaviour and appearance of your app without requiring users to download an app update. Since these changes happen infrequently, you may choose to fetch and load them once, during app startup. However, note that realtime Remote Config is also possible if desired.

Conclusion

Ultimately, deciding between one-time reads and realtime updates boils down to a few questions:

  • Does your backend or API support realtime updates in the first place?
  • How often does the data change?
  • Will the app feel broken if the data is stale or not updated immediately?
  • What cost considerations come into play?

This is not a once-and-for-all decision.

Perhaps some parts of your app need to always show the latest data, while others don’t. And while my recommendation is to choose realtime updates by default, make sure you evaluate the tradeoffs on a case-by-case basis.

Happy coding!

New Flutter & Firebase Course

If you want to ship your Flutter apps faster, Firebase is a great choice. And in this new course, I cover all the most important features, including Firebase Auth, Cloud Firestore, Firebase Storage, Cloud Functions, and Firebase Extensions. 👇

Want More?

Invest in yourself with my high-quality Flutter courses.

Flutter In Production

Flutter In Production

Learn about flavors, environments, error monitoring, analytics, release management, CI/CD, and finally ship your Flutter apps to the stores. 🚀

Flutter Foundations Course

Flutter Foundations Course

Learn about State Management, App Architecture, Navigation, Testing, and much more by building a Flutter eCommerce app on iOS, Android, and web.

The Complete Dart Developer Guide

The Complete Dart Developer Guide

Learn Dart Programming in depth. Includes: basic to advanced topics, exercises, and projects. Last updated to Dart 2.15.

Flutter Animations Masterclass

Flutter Animations Masterclass

Master Flutter animations and build a completely custom habit tracking application.