Seeking Insights on Local Caching and Real Time Updates for Mobile App

Hello everyone,

I want to implement local caching and real time updates for a production flutter mobile app.

Current Situation
I professionally develop a Flutter App which is used in production by a couple thousand users daily.
As a centralized data source we have a Mongo DB. This data can be accessed via a REST API. Additionally, we receive events through a self-implemented Socket Server. For local caching we do not have a consistent solution for now.

Challenges
Initially this App was mainly for displaying / reading data. With the latest features of our App we further enabled the users to write and modify data. This caused quite some problems with keeping the Data and UI in sync without continuously querying the remote data source.

The High Level Solution Iā€™m thinking of
I am considering using Drift. Whenever the Socket Server sends a new event, I insert it in the local DB. With Drifts Stream Queries I can listen to those updates.
Some questions I have about this approach:

  • How can I handle the Situation when the App misses a Socket Server event?
  • Given that our REST API and Socket Server is self-made, what functionalities should I ensure are present in the API and/or Socket Server to effectively implement local caching and real-time updates?

Looking for Insights
I believe this implementation of local caching and real time updates will cause quite some decisions regarding our overall architecture. Before we make those decisions I would love to here some opinions, experiences and hints on this topic.

I very much appreciate any shared knowledge!

4 Likes

Thatā€™s a wonderful question to have!

Some remarks initially but Iā€™m happy to chat more.

  1. Make sure to set up drift with migrations and isolate access so that big updates will not cause UI jank,
  2. Sometimes itā€™s worth exposing drift stream queries via separate behavior subject aggregating data for multiple customers
  3. Make sure to close your websocket connections when app goes to background, otherwise you may encounter high battery usage
  4. Your http/websocket clients should be traceable so on iOS you may want to check out cupertino_http with websocket adapter, happy to share example. This way you can observe the network traffic when debugging
  5. For reconciliation/synchronization I recommend having a central place where your data gets fetched on app coming back to the foreground/starting up. Itā€™s up to you whether itā€™s fetched via rest API or websocket, the easiest is to keep track of the ā€œlast updated timestampā€ and just fetch anything newer. Just observe the app lifecycle and orchestrate your updates as needed.
  6. Decide on conflict resolution strategy. In my case I went with last write wins.
  7. For user actions I would recommend having persistent event queue. By storing actions in database table you know which ones need to be sent to the backend, can retry in case of failure etc. The action description can be just some id and copy of the payload.
  8. I really like and recommend approach you described where you listen to your websocket connection, save the data to the database and then rely on drift to emit these changes. This is what I did and I saw other examples of this approach invented several times elsewhere

Feel free to ask follow up questions, this is my favorite topic

5 Likes

First of all, thank you very much, @dominik, for your friendly and informative reply! Iā€™ve already learned a lot from your message, but naturally, new questions and thoughts have arisen. I will link my questions and thoughts to your remarks below :point_down:

:bulb: My thoughts of seperating concerns between isolates is the following:

  • Main isolate: Takes care of the UI and reading and writing data to the drift DB.
  • Second isolate: Takes care of syncing the local DB to the backend. Drift migrations will also run in this isolate.

:question: Is this a good setup? Are there any problems I should be aware of when setting up things this way?

:bulb: My thoughts: We are currently not using rxdart and therefore no BehaviorSubject. As far as I can tell from my research of the rxdart package I can achieve a similar effect with riverpod stream provider (which we are currently using).
:question:rxdart seems like a valuable addition, but Iā€™m considering whether itā€™s worth introducing this extra dependency for now. I try to minimize the introduction of new concepts in our project at the same time.

:question:Can you please share your example for cupertino_http? I donā€™t have much experience with different HTTP and websocket clients, so it would be helpful to see the advantages of using cupertino_http.

:question: What is e.g. a ā€œcentral placeā€ for fetching data on app coming back to the foreground/starting up? A ā€œcentral placeā€ which is coming to my mind is the home screen (the first UI screen the user is shown).
:question: What exactly is meant with ā€œkeep track of the ā€˜last updated timestampā€™ and just fetch anything newerā€? Should I query the backend for entries with a created or updated timestamp newer than the ā€œlast updated timestampā€? Or should the backend / remote DB (Mongo DB for us) also have something like an event queue so I can fetch the latest events instead of the latest modified or created entries?
I can imagine both to work and still think there are some approaches which are significant better (more efficient, faster, more flexible ā€¦) than others.
:question: With ā€œobserve the app lifecycleā€œ you mean that the data is fetched in the order in which it is needed? If there is some data which is only displayed if the user clicks 3 different buttons this should probably be loaded after the data which the user is shown immediately without a single button press.

:question: Can you give an example or some references how to implement such a persistent event queue?

  • Main isolate: Takes care of the UI and reading and writing data to the drift DB.
  • Second isolate: Takes care of syncing the local DB to the backend. Drift migrations will also run in this isolate.

Even for reading data for UI you may want to use separate isolate so that data serialization does not affect rendering. Depending on your data size it may not have noticeable effect.

We are currently not using rxdart and therefore no BehaviorSubject.

My personal view is that rxdart is close to standard Dart library, so I donā€™t have reservations when introducing that. Similarly I donā€™t feel like adding package:collection is a 3rd party dependency. Riverpod does a bit of magic to make streams synchronous, so perhaps it wonā€™t be compatible with BehaviourSubject.

:question:Can you please share your example for cupertino_http?

This will be helpful if you ever would like to monitor traffic using e.g. Proxyman

Using this adapter the initialization of our graphql client looks as follows:

void initializeClient() {
    final authLink = AuthLink(
      getToken: _getToken,
    );

    Client? httpClient;
    if (Platform.isIOS) {
      final config = URLSessionConfiguration.defaultSessionConfiguration()
        ..cache = URLCache.withCapacity(memoryCapacity: 2 * 1024 * 1024)
        ..httpAdditionalHeaders = {
          'User-Agent': 'flutter',
          // any extra
        };
      httpClient = CupertinoClient.fromSessionConfiguration(config);
    }

    final baseLink = HttpLink(
      baseUrl,
      httpResponseDecoder: isolateHttpResponseDecoder,
      defaultHeaders: {
        'Content-Type': 'application/json',
        // any extra
      },
      httpClient: httpClient,
    );

    final wsLink = gql_ws.TransportWebSocketLink(
      gql_ws.TransportWsClientOptions(
        socketMaker: gql_ws.WebSocketMaker.generator(() async {
          if (Platform.isIOS) {
            return AdapterWebSocketChannel(
              CupertinoWebSocket.connect(
                Uri.parse(baseWs),
                protocols: ['graphql-transport-ws'],
                config: URLSessionConfiguration.defaultSessionConfiguration(),
              ),
            );
          } else {
            return ws.WebSocketChannel.connect(
              Uri.parse(baseWs),
              protocols: ['graphql-transport-ws'],
            );
          }
        }),
        graphQLSocketMessageDecoder: customDecoder,
        retryWait: (retries) {
          _log.i('Retrying websocket connection in 5 sec, attempt $retries');
          return Future.delayed(const Duration(seconds: 5));
        },
        keepAlive: const Duration(seconds: 30),
        connectionParams: () async {
          var token = await _getToken();

          return {
            'headers': {
              'Authorization': token,
              'Hasura-Client-Name': 'flutter',
              'Content-Type': 'application/json',
            },
          };
        },
      ),
    );

    final httpsLink = authLink.concat(baseLink);
    final link = Link.split(
      (request) {
        return request.isSubscription;
      },
      wsLink,
      httpsLink,
    );

    _client = GraphQLClient(
      link: link,
      cache: GraphQLCache(
        store: InMemoryStore(),
      ),
      defaultPolicies: DefaultPolicies(
        watchQuery: _policies,
        query: _policies,
        mutate: _policies,
        subscribe: _policies,
        watchMutation: _policies,
      ),
    );
    ...
}

And isolate-converters

import 'dart:convert';
import 'dart:isolate';

import 'package:flutter/foundation.dart';
import 'package:gql_websocket_link/gql_websocket_link.dart';
import 'package:http/http.dart' as http;
Future<Map<String, dynamic>?> isolateHttpResponseDecoder(
  http.Response httpResponse,
) async =>
    Isolate.run(
      () => const Utf8Decoder().fuse(const JsonDecoder()).convert(httpResponse.bodyBytes) as Map<String, dynamic>?,
    );

// ignore: prefer_function_declarations_over_variables
final GraphQLSocketMessageDecoder customDecoder =
    (dynamic message) async => await compute(jsonDecode, message as String) as Map<String, dynamic>;

What is e.g. a ā€œcentral placeā€

In my case itā€™s a class (cubit in that case) that lives in a global scope of the app and has access to my data repositories and gets notified about lifecycle events. When the lifecycle changes to paused it stops all websocket connections, and when it returns to active it starts them again.

Should I query the backend for entries with a created or updated timestamp newer than the ā€œlast updated timestampā€?

Yes, thatā€™s one of the strategies to only fetch data that has changed in the meantime. Imho itā€™s better to offload this work to the client, so that client knows what is the most recent timestamp and just asks for anything that is newer. Probably you may want to have a way to synchronize all data occassionally. Also you need to consider cases of deleting data. In practice the easiest is to introduce soft-deletes so that when some entity is deleted, you get notified about it because its updated_at changed as well as is_deleted flag.

:question: With ā€œobserve the app lifecycleā€œ you mean

By app lifecycle I mean this AppLifecycleListener class - widgets library - Dart API

:question: Can you give an example or some references how to implement such a persistent event queue?

My presentation here at 25:00: From Network Failures to Offline Success: A Journey of Visible App - droidcon

Elianā€™s part in this talk after about 15:00 Going offline first with Flutter: Best practices - droidcon

1 Like

A different opinion, but I would consider syncing data on the backend to a Firestore database. Firestore handles both local caching and realtime + offline updates. You get Protobuf over gRPC and the client is a breeze to setup. Wonā€™t have to scratch your heads over rare bugs in your own sync engine and can ship faster.

1 Like

Just for better understanding, do you need to store data locally to allow offline-first usage?

Like Yonatan Levin proposes in his great droidcon talk ā€œā€˜Offlineā€™ is not an errorā€, I want to treat being offline as just another kind of state. So yes I do strive for storing data locally to allow offline-first usage. But eventually this local data has to be synced remotely.
So offline-first never means offline-only for my situation.

If you need any further information for better understanding donā€™t hesitate to ask!

Thank you for sharing this different opinion! I have considered this option, but I havenā€™t had the chance to explore it in depth since our data isnā€™t currently in a Firestore database, and my influence on our database and backend decisions is somewhat limited.

If you could point me towards some good references to help me dive deeper into this topic, I would really appreciate it! Even the official Firestore documentation would be helpful. I always find it easier to engage with content when itā€™s recommended by someone with more experience in that topic. With not much knowledge and experience itā€™s hard to tell if content is pointing me in the right direction.