Sometimes you may want to build your own Database API. This certain helps make complex cases easier. You may also could use a mixture of both. If you want optimistic updates, offline mode, and realtime updates on the client, you may stick with the Firebase Client SDK. However, if you want to write more complex security than Firestore Rules is capable of doing alone, you may just disable all your client side writes, and put them on the server side.
TL;DR#
Method | ✅ Best For | ⚠️ Avoid When |
---|---|---|
Server Side Increment | Large Collection counts. Sorting by counters. 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 Side Server Count | Large Collection counts. Sorting by counters. 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#
Disable Client Side Writes#
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /users/{document} {
allow read;
allow write: if false;
}
match /todos/{document} {
allow read;
allow update;
allow create, delete: if false;
}
match /_counters/{document} {
allow read;
allow write: if false;
}
}
}
Or perhaps you want to allow updates, but not creates and deletes on the client side. This comes in handy when you need to require a batch or transaction with your create and delete. This certainly makes sense for a counter.
Callable Function#
Generally speaking, I recommend using firebase-admin
your app itself, like SvelteKit or NextJS. You could use custom endpoints or Server Actions to write to your database how you want to. This also allows you to run quicker Firestore updates from the Edge, instead of serverless Cloud Run architecture.
However, there are plenty of cases when you want to just call a Firebase Function directly to have your code run on the server. This is great for Flutter, React Native, or native applications. Either way the premise will be the same. Start up your firebase-admin
, use transactions, and you can do what you want.
Firebase Functions#
Create two Firebase functions for the delete
and create
cases. You want to update the count for each use case, while simultaneously adding or deleting a new record.
import { firestore } from 'firebase-admin';
import { getApps, initializeApp } from 'firebase-admin/app';
import { onCall } from 'firebase-functions/v2/https';
if (!getApps.length) {
initializeApp();
}
const db = firestore();
export const addTodo = onCall(todoChange);
export const deleteTodo = onCall(todoChange);
Note: Make sure to import from firebase-admin/app
while testing.
Client Call#
From your client code you’re going to run the two callable functions addTodo
and deleteTodo
. You will pass in either the id
or the text
field depending on your use case.
import { httpsCallable } from "firebase/functions";
...
export const addTodo = async (text: string) => {
httpsCallable(functions, 'addTodo')({
text
});
};
export const deleteTodo = (id: string) => {
httpsCallable(functions, 'deleteTodo')({
id
});
};
Server Count#
The user login credentials are automatically passed to the request
object as request.auth
. If the user is not logged in, we must throw an error.
This code runs a transaction that gets the current count values for todoCount
on the user document and count
on the counter document. See Firestore Cloud Functions Counter if you need more understanding of why. This will keep a count of your todos
and the total todos
. We use the server count method here, although we could use regular increments. Unlike a Firestore Trigger Function, these functions could be called more than once without problems. Worst case scenario you get two todo
items that are equal with different ids. The count would have to be correct due to the transaction nature of adding an item with the count increment. If you want to save on counting costs, an increment might be the better way to go in this scenario.
const todoChange = (request: https.CallableRequest) => {
if (!request.auth) {
throw new https.HttpsError(
'unauthenticated',
'The function must be called while authenticated.'
);
}
const uid = request.auth.uid;
const create = !!request.data.text;
return db.runTransaction(async (transaction) => {
// get current doc collection count
const docCountQuery = db.collection('todos').count();
const docCountDoc = await transaction.get(docCountQuery);
// get current user doc count
const userCountQuery = db
.collection('todos')
.where('uid', '==', uid)
.count();
const userCountDoc = await transaction.get(userCountQuery);
// create or delete todo
if (create) {
const todoRef = db.collection('todos').doc();
transaction.create(todoRef, {
uid,
text: request.data.text,
complete: false,
createdAt: FieldValue.serverTimestamp()
});
} else {
const todoRef = db.doc('todos/' + request.data.id);
transaction.delete(todoRef);
}
const i = create ? 1 : -1;
// update doc count
const countRef = db.doc('_counters/todos');
transaction.set(countRef, {
count: docCountDoc.data().count + i
}, { merge: true });
// update user doc count
const userRef = db.doc('users/' + uid);
transaction.set(userRef, {
todoCount: userCountDoc.data().count + i
}, { merge: true });
});
}
Realtime Optimistic Problem#
I wanted to present this option, as it could be the way to go in many apps. However, keep in mind you will not have optimistic updates when you call from the server, so your app will only get updated realtime once the process has completed and gets sent back to the client. The same goes for offline mode. In any other database you would have to build your own optimistic update system anyway.
Example App#
Repo: GitHub
Repo Functions: index.ts
An identical version of this app was also built for my Firestore Secure Batch Increment post and my Firestore Cloud Functions Counter post. However, the counter mechanisms vary slightly.