Skip to main content

Command Palette

Search for a command to run...

Firebase Draining Your Wallet? Here's How to Stop Paying for Every Single Read.

Published
8 min read
Firebase Draining Your Wallet? Here's How to Stop Paying for Every Single Read.
S
I hate slow systems.
In this blog, we’ll focus on using Dart/Flutter for our code examples, but keep in mind that you can apply these methods in any programming language. The key here is to focus on the techniques, not just the language!

If you're here, you probably already know that Firebase is great for building apps with real-time data. However, many developers run into a big issue—Firebase costs can quickly get out of hand due to too many read operations.

In 2018, a startup in Colombia faced this exact problem when they scaled up to 2 million daily active users (DAUs). A small, but costly mistake in their code ended up leaving them with a $30,356.56 bill from Google Clouds in just 72 hours. Why? Because that tiny error caused 2 million users to each trigger 16,000 document reads, leading to over 40 billion requests to Firestore in less than 48 hours. Read the full article

You can avoid such excessive costs by optimizing your reads. In this blog, we'll dive into specific strategies to stop paying for every single read without sacrificing app performance.

Understanding Firebase Read Costs

Firebase's pricing model is simple. In Firestore, you are charged based on the number of documents you read. Each time a document is read from the database—whether through a query, real-time listener, or a simple fetch—it counts as a read.

Check out the Firestore Pricing here.

Before jumping into solutions, it’s important to understand where things can go wrong.

  • Fetching unnecessary data: For instance, if you’re retrieving large collections when you only need a few documents, you’re paying for extra reads.

  • Inefficient queries: Poor query structuring can result in pulling unnecessary data or performing repeated reads.

  • Overuse of real-time syncing: Real-time listeners are great, but they can result in multiple reads as they sync data even when it’s not needed immediately.


Strategies to Reduce Firebase Read Costs

Optimize Your Data Structure

A well-organized database can greatly cut down the number of reads. If your Firestore database isn't structured efficiently, it can cause you to fetch more data than needed, leading to extra read costs.

Choosing when to use nested data vs flattened data is crucial for reducing unnecessary reads in Firestore.

Nested Data Flattened Data
If you often need to access both parent and child data at the same time, using a nested structure lets you fetch them with just one read. Use this structure when you expect many related items, such as comments, tasks, or messages, that can quickly exceed document size limits.
💡
Split data into multiple collections to avoid reading large, unnecessary datasets.

Example: Instead of storing all user posts in one collection, divide them into smaller subcollections by category or user ID.

Query Optimization

Firestore is a NoSQL database, and it's important to use its indexing features. Writing efficient queries can help lower the number of documents read.

Firestore automatically indexes each document by its document ID. But for complex queries, like filtering on several fields, you might need to create composite indexes.

Example: If you want to query users by both age and city, you'll need a composite index for age and city.

Filtering documents with where() clauses retrieves only the necessary data.

final QuerySnapshot querySnapshot = await FirebaseFirestore.instance
      .collection('users')
      .where('age', isGreaterThanOrEqualTo: 18)
      .where('city', isEqualTo: 'New York')
      .get();

Make sure to combine filters that make sense together to reduce the number of reads.

Implement Pagination Using limit() for Efficient Data Retrieval

final int pageSize = 10;
DocumentSnapshot? lastVisible;

Future<void> getPosts() async {
  final query = FirebaseFirestore.instance
      .collection('posts')
      .orderBy('createdAt')
      .limit(pageSize)
      .startAfterDocument(lastVisible!);
  
  final querySnapshot = await query.get();

  querySnapshot.docs.forEach((doc) => print(doc.data()));
  lastVisible = querySnapshot.docs.isNotEmpty ? querySnapshot.docs.last : null;
}

Limiting documents can significantly reduce costs and improve performance, especially when dealing with large datasets.

💡
Full collection scans occur when you don't use indexes, making Firestore read every document in a collection. To avoid this, always use indexed fields in your queries.

Avoid Unnecessary Real-time Listeners

Real-time listeners are powerful but expensive if used without careful consideration. In many cases, you don't need real-time updates for every piece of data.

Manual Refreshing: Instead of relying on real-time updates, users can manually trigger a refresh to load the latest data.

💡
Firestore offers offline persistence, enabling data to be cached on the client and synchronized later when the app reconnects. This feature can significantly reduce the number of reads, minimizing network calls during offline usage.

In this blog, we will look at advanced caching methods that can greatly reduce your Firestore costs. These strategies are more than just basic offline caching and can save you a lot of money while improving your app's performance.


Advanced Strategies to Reduce Firebase Costs

You can use SharedPreferences to store small pieces of data that are frequently accessed, like user info or settings. This works well for data that doesn’t change much. However, SharedPreferences is only suitable for small amounts of data. If you store too much, it can slow down your app, use more memory, and hit storage limits.

import 'package:shared_preferences/shared_preferences.dart';

Future<void> saveUserInfo(String name, int age, String email) async {
  final SharedPreferences prefs = await SharedPreferences.getInstance();
  await prefs.setString('userName', name);
  await prefs.setInt('userAge', age);
  await prefs.setString('userEmail', email);
}

For example, you can store user information in SharedPreferences as small chunks of data that are repeatedly used throughout your application.

Use Provider for efficient state management to reduce Firestore reads.

Without proper state management, your app might request the same data from Firestore multiple times, increasing the number of reads unnecessarily. By using Provider, once data is fetched from Firestore, it can be stored and accessed efficiently throughout the app without additional Firestore calls.

import 'package:flutter/material.dart';
import 'package:cloud_firestore/cloud_firestore.dart';

class UserProvider extends ChangeNotifier {
  Map<String, dynamic>? _userData;
  Map<String, dynamic>? get userData => _userData;

  Future<void> fetchUserData(String userId) async {
    if (_userData != null) return; // Skip fetching if data is already loaded

    try {
      final doc = await FirebaseFirestore.instance.collection('users').doc(userId).get();
      _userData = doc.data();
    } catch (e) {
      print('Error fetching user data: $e');
    } 
      
    notifyListeners();
  }
}

Don’t forget to add the Provider class to the main.dart file and then use it in the UI. This ensures the data is fetched only once when the app starts and is shared efficiently across all UI components, screens, and pages.

If you know that the data is not going to change frequently, you can save it on the client side, essentially caching the data. This can be achieved through various methods, such as using Hive, SQLite, or path_provider, each serving a different purpose.

Hive SQLite path_provider
NoSQL, key-value pairs Relational (SQL queries) File system access (no database)
Simple setup, fast for small data Complex setup, powerful for large datasets Minimal setup, no querying or relations
💡
Although path_provider is easy to set up, it is not efficient for storing JSON data, as it requires serializing and de-serializing the data into your model, which can be a heavy task. Therefore, Hive is often used instead.

Server-Side Optimization for Cost Reduction

If all the users are requesting the same data from your Firebase database, it can unnecessarily increase your reads. To avoid this, you can use the Firebase Admin SDK to create bundles, request the data only once, store it in those bundles, and send the bundles to the clients. This will reduce Firebase reads and improve your app's performance, as it only needs to request the data once.

from google.cloud import firestore
from google.cloud.firestore_bundle import FirestoreBundle

db = firestore.Client()
bundle = FirestoreBundle("bundle-name")

for user in db.collection("users").stream():
    bundle.add_document(user.get())
    
    for post in db.collection("posts").where("user_id", "==", user.id).stream():
        bundle.add_document(post.get())

bundle_buffer = bundle.build()

with open("cacheBundle.bin", "wb") as file:
    file.wrtie(bundle_buffer)

The above code snippet might not be entirely correct as I forgot the exact syntax, but it was something similar to this. Check Docs

Once you've created the Firestore bundle and serialized it, you can save the bundle as a file (e.g., cacheBundle.bin) and send it to all of your clients via HTTP or upload it to Firebase Cloud Storage. This way, clients can access the cached data from the bundle without repeatedly making requests to Firebase Database.

Conclusion

Optimizing Firebase reads can help you save costs and improve app performance. By structuring your data efficiently, optimizing queries, minimizing real-time listeners, and using caching strategies, you can reduce unnecessary reads without sacrificing performance.

Feel free to share your own methods or any corrections in the comments below. Happy coding, and may your Firebase costs stay low while your app thrives!