Home
>
Firestore Cloud Functions Counter

Firestore Cloud Functions Counter

19 min read
Jonathan Gamble

jdgamble555 on Sunday, February 11, 2024 (last modified on Wednesday, February 21, 2024)

The original and classic way to count documents in a Firestore Collection or Query is to use Firebase Cloud Functions. What seems like such an easy thought process can get complicated very quickly once you realize how the Google Cloud Infrastructure actually works with cold starts, scaling, after trigger mechanisms.

TL;DR

Method ✅ Best For ⚠️ Avoid When
Increment Transaction Large Collection counts. Sorting by counters. Need for realtime counter. You want to avoid complex rules. Saving price on counting. You need to display a count once on small collections. You prefer dealing with Firestore rules over Firebase Functions. You worry about accuracy.
Server Count Transaction Large Collection counts. Sorting by counters. Need for realtime counter. You want to avoid complex rules. You need accuracy. You need to display a count once on small collections. You prefer dealing with Firestore rules over Firebase Functions. You don’t want to be charged for counting.

Setup

In order to keep track of our document counts, we are going to use a meta collection called _counters. Each document in this collection will have an ID that matches a collection we want to count. So a posts collection, will have the posts count stored in _counters/posts under the key count. This is exactly how the count is stored in the Firestore Secure Batch Increment.

Option 1: Increment Transaction

Here we update the count by triggering create and delete events in a Firebase Cloud Function and incrementing or decrementing a counter.

How Things Should Work

You would think you should be able to create one function that increments or decrements a Firestore collection counter with a simple trigger function.

	export const todoCounter = firestore
    .document(`todos/{docId}`)
    .onWrite((change) => {

        // get counter collection
        const db = change.after.ref.firestore;
        const countRef = db.doc(`_counters/${collection}`);
        const create = !change.before.exists && change.after.exists;

        // document created
        if (create) {
            return countRef.update{
                count: FieldValue.increment(1)
            });
        }

        // document removed
        return countRef.update({
            count: FieldValue.increment(-1)
        });
    });

Problem 1 - Write Function

Although we could use an onWrite function to handle both use cases, it will also trigger on updates. Even better, I used to think a universal onWrite function could handle any use case. That is when we trigger any document change like:

	firestore.document(`${collection}/${documentId}`).onWrite()

Both of these are a terrible idea if you want to save money. These functions will get triggered on every single write, including updates.

Solution 1 - Create and Delete

In order to correctly count the number of documents, you need to count the create and delete only, limiting other function invocations. Create two separate Cloud Function Trigger Functions.

	export const addTodo = firestore
    .document(`todos/{docId}`)
    .onCreate(counterFunction);

export const deleteTodo = firestore
    .document(`todos/{docId}`)
    .onDelete(counterFunction);

Problem 2 - Update

If you use the update method on a document instead of set, your document must exist with that field. It is better to cover all cases. Use set with merge.

	countRef.set({
    count: FieldValue.increment(1)
}, { merge: true });

Problem 3 - Write Idempotent Functions

Because of the way the serverless environment works, Cloud Functions could be called more than once. That means your counter trigger functions could get called more than once, giving an incorrect number of documents. You must write idempotent functions, or functions that produce the same results no matter how many times they are ran.

Solution 3 - Event Handling

Luckily Cloud Functions create a unique event id for every single event that triggers a function call. This means two functions that are called from the same event will have the same event id. If we can store this event, we can see if our trigger action, changing a count increment, has already come to fruition. So, we create another meta collection called _events to store these event ids along with the event type and document reference. We want to use an atomic transaction method to ensure all or nothing success.

Transaction

We want to run everything in a transaction. If the event id exists, we want to cancel the transaction. Otherwise, we want to increment and add a new event.

	return db.runTransaction(async (transaction) => {

    // check for event id
    const eventRef = db.doc(`_events/${eventId}`);
    const eventDoc = await transaction.get(eventRef);

    // do nothing, increment already ran
    if (eventDoc.exists) {
        return null;
    }

    const countRef = db.doc(`_counters/${collection}`);

    // setup increment or decrement
    const create = 'google.firestore.document.create';
    const i = context.eventType === create ? 1 : -1;

    transaction.set(countRef, {
        count: FieldValue.increment(i)
    }, { merge: true });

    // add event
    return transaction.set(eventRef, {
        'type': context.eventType,
        'createdAt': FieldValue.serverTimestamp(),
        'documentRef': snap.ref
    });
    
});

We can see if we need to increment or decrement by checking if the event type is a create or a delete.

	const create = 'google.firestore.document.create';
const i = context.eventType === create ? 1 : -1;

Note: We get the database and event id through the onCreate and onDelete parameters snap and context.

	const db = snap.ref.firestore;
const eventId = context.eventId;

// get the collection name or manually define it
const collection = snap.ref.path.split('/')[0];

Event Cleanup

While we could use Time To Live in Firestore, I prefer an automatic event cleanup mechanism. We don’t want our event documents getting out of control and wasting space, which costs money. Since a function can’t run longer than 9 minutes, and a cold start is at worst a few seconds, 10 minutes sounds like a good expire time.

	// remove all old events
const tenMinutesAgo = new Date(Date.now() - 10 * 60 * 1000);
const oldEventDocs = await db.collection('_events')
    .where('createdAt', '<=', tenMinutesAgo)
    .get();

if (!oldEventDocs.empty) {
    oldEventDocs.forEach(doc => {
        batch.delete(doc.ref);
    });
}

// commit all actions at once
return batch.commit();

Basically we find any documents that were created more than 10 minutes ago, and then delete them in the transaction. This keeps our events clean.

Reusable Function

I created a reusable function for any collection.

	export const eventCounter = async (
    snap: QueryDocumentSnapshot,
    context: EventContext
) => {

    const db = snap.ref.firestore;
    const eventId = context.eventId;

    // get the collection name
    const collection = snap.ref.path.split('/')[0];

    // get all expired events
    const tenMinutesAgo = new Date(Date.now() - 10 * 60 * 1000);
    const oldEventDocs = await db.collection('_events')
        .where('createdAt', '<=', tenMinutesAgo)
        .get();

    return db.runTransaction(async (transaction) => {

        // check for event id
        const eventRef = db.doc(`_events/${eventId}`);
        const eventDoc = await transaction.get(eventRef);

        // remove old events
        if (!oldEventDocs.empty) {
            oldEventDocs.forEach(doc => {
                transaction.delete(doc.ref);
            });
        }

        // do nothing, increment already ran
        if (eventDoc.exists) {
            return null;
        }

        const countRef = db.doc(`_counters/${collection}`);

        // setup increment or decrement
        const create = 'google.firestore.document.create';
        const i = context.eventType === create ? 1 : -1;

        transaction.set(countRef, {
            count: FieldValue.increment(i)
        }, { merge: true });

        // add event
        return transaction.set(eventRef, {
            'type': context.eventType,
            'createdAt': FieldValue.serverTimestamp(),
            'documentRef': snap.ref
        });

    });
}

And you can call the function for both create and delete triggers:

	export const addTodo = firestore
    .document(`${collection}/{docId}`)
    .onCreate(eventCounter);

export const deleteTodo = firestore
    .document(`${collection}/{docId}`)
    .onDelete(eventCounter);

Handling Other Counters

You may also want to handle counting other queries. For example, how many todos or posts each user has. This would would basically be keeping track of a count filtering your collections. Just add the count to the transaction.

	// user counter
const docData = snap.data();
const uid = docData.uid;
const userRef = db.doc('users/' + uid);

transaction.set(userRef, {
    [collection + 'Count']: FieldValue.increment(i)
}, { merge: true });

Although you cannot get the user id from the invoked trigger function, you can get it from the new or deleted document. In this case we are saving it as uid. Remember to add the necessary Firestore Rules for this.

	allow create: if request.resource.data.uid == request.auth.uid;
allow delete: if resource.data.uid == request.auth.uid;

This function is reusable and will take the collection name and add count after it on the user document. So each user’s post in a posts collection will be called postsCount on the user document and so forth. If you prefer something like postCount or your own name, you will need to change this manually.

Price

Using this function you will create an event document (and read it again later and eventually delete it), update the user count, and update the collection count. This is 1 read and 3 writes for each document change. Including the original document change, this is 1 read and 4 writes. In the rare case the function gets called again, there would be an extra read on top of that.

Option 2: Server Count Transaction

Increments, especially in Cloud Functions, can get out of sync if you’re not extremely careful, test your functions fully, and don’t accidently do something stupid in another function. Luckily there is also another option for those of us that want things to be cleaner. We can use the server count function to grab the collection count (and user count if necessary), then update the counter with that value. Since there are no increments involved, this would be extremely accurate, and an idempotent function that won’t require event handling.

	export const transactionCounter = (snap: QueryDocumentSnapshot) => {

    const db = snap.ref.firestore;

    // get the collection name
    const collection = snap.ref.path.split('/')[0];

    return db.runTransaction(async (transaction) => {

        // get doc uid
        const docData = snap.data();
        const uid = docData.uid;

        // get current doc collection count
        const docCountQuery = db.collection(collection).count();
        const docCountDoc = await transaction.get(docCountQuery);

        // get current user doc count
        const userCountQuery = db
            .collection(collection)
            .where('uid', '==', uid)
            .count();
        const userCountDoc = await transaction.get(userCountQuery);

        // update doc count
        const countRef = db.doc(`_counters/${collection}`);
        transaction.set(countRef, {
            count: docCountDoc.data().count
        }, { merge: true });

        // update user doc count
        const userRef = db.doc('users/' + uid);
        transaction.set(userRef, {
            [collection + 'Count']: userCountDoc.data().count
        }, { merge: true });

    });
}

The premise is the same, and this function is reusable just like the other function. Less headache, more accurate. However, it is worth considering the extra millisecond delay for counting large collections, as well as the price per 1-1000 documents you would be counting. I

Price

This function would get 2 writes, plus another 2 read per 1-1000 documents in the collection or queries. Assuming you have a small collection, you would have 2 reads and 2 writes, however, as your collection grow, this could be more expensive.

Update with 2nd gen Cloud Functions

I built a version for Firebase Functions V2 in the code as well. The core code is the same, but there are a few changes. The events use onDocumentWritten and onDocumentCreated. Everything else is the same. See the source code.

Example App

Server Todo Counter

Repo: GitHub

Repo Functions: count.ts

I built a working todo app as in my Firestore Secure Batch Increment post and Firestore Server Side Counter post, except with Firebase Function Triggers. Everything else is exactly the same.


Related Posts

© 2024 Code.Build