Serve bundled Firestore content from a CDN

Many applications serve the same content to all users on first page load. For example a news site may show the latest stories, or an e-commerce site may show the best-selling items.

If this content is served from Cloud Firestore, each user will issue a new query for the same results when they load the application. Because these results are not cached between users, the application is slower and more expensive than it needs to be.

Solution: Bundles

Cloud Firestore bundles allow you to assemble data bundles from common query results on the backend using the Firebase Admin SDK, and serve these pre-computed blobs cached on a CDN. This gives your users a much faster first load experience and reduces your Cloud Firestore query costs.

In this guide we will use Cloud Functions to generate bundles and Firebase Hosting to dynamically cache and serve bundle content. More information about bundles is available in the guides.

First create a simple public HTTP function to query the 50 latest "stories" and serve the result as a bundle.

Node.js
exports.createBundle = functions.https.onRequest(async (request, response) => {
  // Query the 50 latest stories
  const latestStories = await db.collection('stories')
    .orderBy('timestamp', 'desc')
    .limit(50)
    .get();

  // Build the bundle from the query results
  const bundleBuffer = db.bundle('latest-stories')
    .add('latest-stories-query', latestStories)
    .build();

  // Cache the response for up to 5 minutes;
  // see https://firebase.google.com/docs/hosting/manage-cache
  response.set('Cache-Control', 'public, max-age=300, s-maxage=600');

  response.end(bundleBuffer);
});
      
Java

package com.example;

import com.google.auth.oauth2.GoogleCredentials;
import com.google.cloud.firestore.Firestore;
import com.google.cloud.firestore.FirestoreBundle;
import com.google.cloud.firestore.Query.Direction;
import com.google.cloud.firestore.QuerySnapshot;
import com.google.cloud.functions.HttpFunction;
import com.google.cloud.functions.HttpRequest;
import com.google.cloud.functions.HttpResponse;
import com.google.firebase.FirebaseApp;
import com.google.firebase.FirebaseOptions;
import com.google.firebase.cloud.FirestoreClient;
import java.io.BufferedWriter;
import java.io.IOException;

public class ExampleFunction implements HttpFunction {

  public static FirebaseApp initializeFirebase() throws IOException {
    if (FirebaseApp.getApps().isEmpty()) {
      FirebaseOptions options = FirebaseOptions.builder()
          .setCredentials(GoogleCredentials.getApplicationDefault())
          .setProjectId("YOUR-PROJECT-ID")
          .build();

      FirebaseApp.initializeApp(options);
    }

    return FirebaseApp.getInstance();
  }

  @Override
  public void service(HttpRequest request, HttpResponse response) throws Exception {
    // Get a Firestore instance
    FirebaseApp app = initializeFirebase();
    Firestore db = FirestoreClient.getFirestore(app);

    // Query the 50 latest stories
    QuerySnapshot latestStories = db.collection("stories")
        .orderBy("timestamp", Direction.DESCENDING)
        .limit(50)
        .get()
        .get();

    // Build the bundle from the query results
    FirestoreBundle bundle = db.bundleBuilder("latest-stores")
        .add("latest-stories-query", latestStories)
        .build();

    // Cache the response for up to 5 minutes
    // see https://firebase.google.com/docs/hosting/manage-cache
    response.appendHeader("Cache-Control", "public, max-age=300, s-maxage=600");

    // Write the bundle to the HTTP response
    BufferedWriter writer = response.getWriter();
    writer.write(new String(bundle.toByteBuffer().array()));
  }
}
      

Next configure Firebase Hosting to serve and cache this Cloud Function by modifying firebase.json. With this configuration the Firebase Hosting CDN will serve the bundle content according to the cache settings set by the Cloud Function. When the cache expires it will refresh the content by triggering the function again.

firebase.json
{
  "hosting": {
    // ...
    "rewrites": [{
      "source": "/createBundle",
      "function": "createBundle"
    }]
  },
  // ...
}

Finally in your web application, fetch the bundled content from the CDN and load it into the Firestore SDK.

// If you are using module bundlers.
import firebase from "firebase/app";
import "firebase/firestore";
import "firebase/firestore/bundle" // This line enables bundle loading as a side effect.

async function fetchFromBundle() {
  // Fetch the bundle from Firebase Hosting, if the CDN cache is hit the 'X-Cache'
  // response header will be set to 'HIT'
  const resp = await fetch('/createBundle');

  // Load the bundle contents into the Firestore SDK
  await db.loadBundle(resp.body);

  // Query the results from the cache
  // Note: omitting "source: cache" will query the Firestore backend.
  
  const query = await db.namedQuery('latest-stories-query');
  const storiesSnap = await query.get({ source: 'cache' });

  // Use the results
  // ...
}

Estimated Savings

Consider a news website which gets 100,000 users per day and each user loads the same 50 top stories on initial load. Without any caching, this would result in 50 x 100,000 = 5,000,000 document reads per day from Cloud Firestore.

Now assume the site adopts the technique above and caches those 50 results for up to 5 minutes. So instead of loading the query results for every user, the results are loaded exactly 12 times per hour. No matter how many users arrive at the site, the number of queries to Cloud Firestore stays the same. Instead of 5,000,000 document reads, this page would use 12 x 24 x 50 = 14,400 document reads per day. The small additional costs for Firebase Hosting and Cloud Functions are easily offset by the Cloud Firestore cost savings.

While the developer benefits from the cost savings, the biggest beneficiary is the user. Loading these 50 documents from the Firebase Hosting CDN rather than from Cloud Firestore directly can easily shave 100-200ms or more from the content load time of the page. Studies have repeatedly shown that speedy pages mean happier users.