Firestore Local Cache with Flutter Web

Caching is really important when it comes to the web. It is also possible to implement your own local cache for Firestore, with Flutter Web.

Why should you use caching

Caching is especially important for Flutter Web, because of the rerendering of the screen when setting state. When you use setState, everything in the build will run again. There are some state management packages, that will not rerun, and saves the current state, but most of them will do. Everytime it is rerun, your Firestorecalls will run again.

Not to mention, whenever the screen is resized, the rerendering will occur. When this happens, the network will be flooded with unnecessary Firestore calls.

Firebase is accumulating every call you are sending out, and will bill you at the end of the month. If you exceed the free limits, you can get an unexpected amount. When more people use your application, the more the bill will be. So you should take it into consideration, and use the local cache method I am going to show you.

When should you use caching?

The way I am going to show you is only applicable for Flutter Web and also it makes more sense to do than on mobile. In short, you should cache your data everytime you get the chance.

There are some Firestore methods that you can use caching on:

  1. document()
  2. collection()
  3. collection().where()

Getting a document, getting a list of documents, and querying a list of document.

Implementing local cache for Firestore

Every method is following the same principles and stays at the same class, which is LocalCache.

import 'package:firebase/firestore.dart' as fb;
import 'package:firebase/firebase.dart' as db;
import 'package:localcache/cache_response.dart';

const DEFAULT_CACHE_DURATION = 25;

class LocalCache {
  final int cacheDuration;
  static Map<String, CacheResponse> cache = Map<String, CacheResponse>();

  LocalCache({
    this.cacheDuration = DEFAULT_CACHE_DURATION,
  });
}

We have a cacheDuration property and we can change it from the default 25 seconds. That property indicates for how long we are keeping the data. 25 seconds seems too short, but actually, it gets rid of the resizing issue, which really is the biggest problem.

Also we have the cache property, which is a map of request path and responses. The idea is simple. We are storing the path as a key, and the cacheResponse as the value, which looks like this:

class CacheResponse {
  final DateTime timestamp;
  final dynamic response;

  CacheResponse({this.response, this.timestamp});
}

Simple entity class, which contains a timestamp of the last network call and a dynamic response. We are storing the response in that dynamic variable.

Lets start with the easyiest, allthough all the functions are the same with just minor changes.

1 document()

Future<fb.DocumentSnapshotdocumentCacheWrap(String path, {String additionalInfo, bool isForced}) async {
  String key = path + (additionalInfo ?? "");

  if (isForced ?? false) {
    final res = await db.firestore().doc(key).get();
    cache[key] = CacheResponse(timestamp: DateTime.now(), response: res);
    return Future.value(cache[key].response);
  }

  if (cache.containsKey(key)) {
    if (Duration(seconds: cacheDuration) > DateTime.now().difference(cache[key].timestamp)) {
      return Future.value(cache[key].response);
    } else {
      final res = await db.firestore().doc(key).get();
      cache[key] = CacheResponse(timestamp: DateTime.now(), response: res);
      return Future.value(cache[key].response);
    }
  } else {
    final res = await db.firestore().doc(key).get();
    cache[key] = CacheResponse(timestamp: DateTime.now(), response: res);
    return Future.value(cache[key].response);
  }
}

The documentCacheWrap function has a path parameter and 2 optional parameters, which are the additionalInfo and the isForced. Path is the regular Firestore path that you would give to the firestore document.

With the additionalInfo String you are able to differentiate some similar requests, and isForced is the bool value for when you don’t want to use the cached version of the data, you need the data straight from the web.

The path and additionalInfo is concatenated to form the key, for the cache map. This is a unique identification for your request, then we check every condition like, is the isForced is set, or the cache map already contains the data, if it does we are checking the cacheDuration and if the duration expired, we are getting the value from the web. Same thing when we do not have the key in the map yet.

We are returning a DocumentSnapshot, that we can use the same way we would use like with the default Firestore method.

2 collection()

Future<fb.QuerySnapshotcollectionCacheWrap(String path, {String additionalInfo, bool isForced}) async {
  String key = path + (additionalInfo ?? "");

  if (isForced ?? false) {
    final res = await db.firestore().collection(key).get();
    cache[key] = CacheResponse(timestamp: DateTime.now(), response: res);
    return Future.value(cache[key].response);
  }

  if (cache.containsKey(key)) {
    if (Duration(seconds: cacheDuration) > DateTime.now().difference(cache[key].timestamp)) {
      return Future.value(cache[key].response);
    } else {
      final res = await db.firestore().collection(key).get();
      cache[key] = CacheResponse(timestamp: DateTime.now(), response: res);
      return Future.value(cache[key].response);
    }
  } else {
    final res = await db.firestore().collection(key).get();
    cache[key] = CacheResponse(timestamp: DateTime.now(), response: res);
    return Future.value(cache[key].response);
  }
}

Not much to say about it, this is almost exactly the same as the document one. But this time we are returning a QuerySnapshot.

3 document().where()

Future<fb.QuerySnapshotqueryCacheWrap(String path, String field, String relation, dynamic value,
      {String additionalInfo, bool isForced}) async {
    String key = path + (additionalInfo ?? "") + field + relation + value.toString();
    if (isForced ?? false) {
      final res = await db.firestore().collection(path).where(field, relation, value).get();
      cache[key] = CacheResponse(timestamp: DateTime.now(), response: res);
      return Future.value(cache[key].response);
    }

    if (cache.containsKey(key)) {
      if (DateTime.now().difference(cache[key].timestamp) < Duration(seconds: cacheDuration)) {
        return Future.value(cache[key].response);
      } else {
        final res = await db.firestore().collection(path).where(field, relation, value).get();
        cache[key] = CacheResponse(timestamp: DateTime.now(), response: res);
        return Future.value(cache[key].response);
      }
    } else {
      final res = await db.firestore().collection(path).where(field, relation, value).get();
      cache[key] = CacheResponse(timestamp: DateTime.now(), response: res);
      return Future.value(cache[key].response);
    }
  }

There are additional parameters, that we will use to query the data. The field, relation and value has been added. The same parameters that the collection().where method has. So the real difference is we form the key from the additional parameters too, and we query the data with the where method and again, we are returning a QuerySnapshot.

Example

To use this LocalCache class, I would advise to put it in some kind of Injector class, or create a Singleton Object that contains the LocalCache.

@override
  Widget build(BuildContext context) {
    Future.delayed(Duration.zero, () {
      setState(() {});
    });

    return Scaffold(
      body: FutureBuilder<fb.DocumentSnapshot>(
        future: widget.cache.documentCacheWrap("example/1"),
        builder: (context, snapshot) {
          if (snapshot.hasData) {
            return Container(
              child: Text(snapshot.data.get("text")),
            );
          }
          return Container(
            child: Text("NO DATA"),
          );
        },
      ),
    );
  }

I tested the methods with this example. But you can use this class the same way you would use the generic firestore functions.

Conclusion

Caching is important. If you want to save on the Firebase expenses, developing a local caching system should be your first priority. It is easy and it will appriciate your time invested.

Next time I am going to implement a local cache for REST APIs.

One thought on “Firestore Local Cache with Flutter Web

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s