Firestore DocumentReference Get Local First (fall back to Server if cache doesn't exist) - Kotlin

May 13, 2019
How to maintain freshness of local cache (latest data)

Why get local cache first?

DocumentReference.get(Source.DEFAULT) will always get the latest data (assuming there is network), but the response is slower as it needs to check the server if latest data exist.

DocumentReference.get(Source.CACHE) is fast if local cache exist. If it doesn’t, we need fetch from server with DocumentReference.get(Source.SERVER). This is the reverse of Source.DEFAULT.

If local cache already exist, there is no guarantee DocumentReference.get(Source.CACHE) will return the latest data. To solve this issue, create DocumentReference.addSnapshotListener to make sure the latest data is always saved to local cache.

val firestore = FirebaseFirestore.getInstance()
val docRef = firestore.collection("test").document("test_local_cache")

docRef.addSnapshotListener { documentSnapshot, firebaseFirestoreException ->
    // do nothing, just to make sure server will update local cache
}

Kotlin Extension

fun DocumentReference.getCacheFirst(listener: (DocumentSnapshot?, Exception?) -> Unit) {
    get(Source.CACHE).addOnCompleteListener { task ->
        if (task.isSuccessful) {
            listener(task.result, null)
        }
        else {
            get(Source.SERVER).addOnCompleteListener { task ->
                if (task.isSuccessful) {
                    listener(task.result, null)
                }
                else {
                    listener(null, task.exception)
                }
            }
        }
    }
}

Usage

val firestore = FirebaseFirestore.getInstance()
val docRef = firestore.collection("test").document("test_local_cache")
docRef.getCacheFirst { documentSnapshot, exception ->
    if (documentSnapshot != null) {
        Timber.d("data=${documentSnapshot.data}")
    }
    else {
        Timber.e(exception)
    }
}

Kotlin Coroutines Suspend Function

Using kotlinx-coroutines-play-services.

Include dependencies

dependencies {
    // https://mvnrepository.com/artifact/org.jetbrains.kotlinx/kotlinx-coroutines-play-services
    implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.2.1'
}

Kotlin Extension

suspend fun DocumentReference.getCacheFirstSuspend(): DocumentSnapshot {
    return try {
        // com.google.firebase.firestore.FirebaseFirestoreException: Failed to get document from cache. (However, this document may exist on the server. Run again without setting source to CACHE to attempt to retrieve the document from the server.)
        get(Source.CACHE).await()
    }
    catch (e: FirebaseFirestoreException) {
        // com.google.firebase.firestore.FirebaseFirestoreException: Failed to get document because the client is offline.
        get(Source.SERVER).await()
    }
}

Usage

val handler = CoroutineExceptionHandler { _, exception ->
    Timber.e(exception, "handler")
}

GlobalScope.launch(Dispatchers.Main + handler) {
    val firestore = FirebaseFirestore.getInstance()
    val docRef = firestore.collection("test").document("test_local_cache")
    val doc = docRef.getCacheFirstSuspend()
    Timber.d("data=${doc.data}")
}

Use suspendCoroutine

suspend fun DocumentReference.getCacheFirstSuspend() = suspendCoroutine<DocumentSnapshot> { cont ->
    getCacheFirst { documentSnapshot, exception ->
        if (documentSnapshot != null) {
            cont.resume(documentSnapshot)
        }
        else {
            cont.resumeWithException(exception!!)
        }
    }
}

Usage same as Kotlin Coroutines Suspend Function.

Complete solution using Kotlin Coroutines

  • Send in lifecycleOwner to listen to addSnapshotListener, where it will be removed when lifecycleOwner is destroyed. Refer to Firestore addSnapshotListener with LifecycleOwner.
  • Only first time will try to check server for latest data
  • Subsequent call will will always read from cache (assuming addSnapshotListener is doing it’s job in updating the local cache with latest data)
  • docRef.get().await() is using kotlinx-coroutines-play-services
private val userQuoteHistoryRegistration = AtomicReference<ListenerRegistration>()
suspend fun getUserQuoteHistorySuspend(lifecycleOwner: LifecycleOwner? = null): DocumentSnapshot {
    val docRef = ...

    // only run on the 1st time, when addSnapshotListener not called yet
    if (lifecycleOwner != null) {
        if (userQuoteHistoryRegistration.compareAndSet(null,
                docRef.addSnapshotListener(lifecycleOwner) { documentSnapshot, firebaseFirestoreException ->
                    Timber.d("userQuoteHistory updated")
                }
            )
        ) {
            Timber.d("userQuoteHistory.addSnapshotListener")

            // sadly, I can't return result from addSnapshotListener
            // , as there is no way to tell if the result is cache or latest from server
            // get from server if available, else return cache
            return docRef.get().await()
        }
    }
    // get cache 1st, if not available try server
    return docRef.getCacheFirstSuspend()
}

Usage

val lifecycleOnwer = ... // Actiivty or Fragment

launch(Dispatchers.Default) {
    val doc = getUserQuoteHistorySuspend(lifecycleOnwer)
}

NOTE: Alternatively, you can addSnapshotListener for the lifetime of the application (at Application.onStart), and getCacheFirst will always return cache with the latest data.

This work is licensed under a
Creative Commons Attribution-NonCommercial 4.0 International License.