Trim your JavaScript bundles with Firestore Lite

The Firestore Web SDK is a pretty powerful tool with a lot of useful features - most notably its realtime sync - but many users also find its size intimidatingly bulky. That’s why we developed Firestore Lite, which as the name implies, is a more lightweight version of the full Firestore SDK. It sacrifices some features, such as real-time updates, for a significantly reduced bundle size.

“But I need real-time sync!” you might say. Well, fortunately, there are some tricks to using both SDKs together to get the benefits of both.

Firestore LogoDark Firestore Logo
Firestore Lite

A lightweight, standalone REST-only Firestore SDK at a fraction of the regular Web SDK size.

https://firebase.google.com/docs/firestore/solutions/firestore-lite

In the demo apps in this blog post, we’re able to reduce the bundle size by over 70% by using Firestore Lite instead of Firestore, which cuts bundle load time by half. With a few additional tricks, we can keep those same savings for the initial page and data load, and download the larger bundle for real-time updates after the initial page and its data have fully loaded.

Let’s start with how to get Firestore Lite into your app.

$npm i firebase
Copied!

Importing Firestore Lite

npm

You don’t need to install a separate package for Firestore Lite. If you have installed Firebase through NPM (version 9.0.0 or greater), you already have it. Instead of importing methods from firebase/firestore, import the same methods from firebase/firestore/lite. (Note, Firestore Lite does not have some Firestore methods, such as onSnapshot.)

index.js
// Importing regular Firestore methods.
import { getFirestore, doc, getDoc, onSnapshot } from 'firebase/firestore';
// Importing Firestore Lite methods.
import { getFirestore, doc, getDoc } from 'firebase/firestore/lite';
Copied!

CDN

If you use ESM and want to use browser modules, you can also import them from the CDN (replace “9.17.2” with whatever the current version of Firebase is):

index.js
import { getFirestore, doc, getDoc } from 'https://www.gstatic.com/firebasejs/9.17.2/firebase-firestore-lite.js'
Copied!

Firestore vs Firestore Lite Bundle Size Comparison

To demonstrate, let’s start with a baseline app that uses the standard Firestore SDK. To see a repo with all the demos mentioned in this post, plus webpack config and helper functions, check out firelite-demo.

This app subscribes to a Firestore document and writes any updates onto the page - a pretty common use case for Firestore.

Firestore

index.js
/**
 * I will skip the imports section in future code samples to save some
 * space. Expect them to be similar or identical.
 */

import { initializeApp } from "firebase/app";
import { getFirestore, doc, onSnapshot } from "firebase/firestore";
import { renderDataOnPage } from "./render";

// Create this file and export your own project config from it.
import { firebaseConfig } from "./firebase-config";

const app = initializeApp(firebaseConfig);
const firestore = getFirestore(app);
const docRef = doc(firestore, "items/item1");

onSnapshot(docRef, (docSnap) => {
  renderDataOnPage(docSnap.data().field1);
});
Copied!

Once Webpack compiles it, the JS bundle comes out to about 196K (61K gzipped), which most would consider somewhat hefty.

And suppose you don’t even want real-time updates. Suppose this page only requires a one-time fetch of data (a message of the day, the contents of an article, or other infrequently-changing data), and you only need getDoc(), not onSnapshot(). Surely that ought to save you some bytes. Let’s try that:

index.js
const app = initializeApp(firebaseConfig);
const firestore = getFirestore(app);
const docRef = doc(firestore, "items/item1");

const docSnap = await getDoc(docRef);

renderDataOnPage(docSnap.data().field1);
Copied!

In my setup this complies to 194K (60.4K gzipped), which is basically no difference. The reasons are complicated but basically the Firestore sync engine is tightly integrated into every part of the Firestore SDK and creating a separate getter function that is not coupled with it would likely involve a lot of duplicate code.

Firestore Lite

But it hardly seems fair that you’ve made a big sacrifice in giving up real-time updates and have gotten nothing back in exchange for it. No fear, Firestore Lite can now reward you for this sacrifice.

Let’s change one line:

index.js
// Previously: import { getFirestore, doc, onSnapshot } from "firebase/firestore";
import { getFirestore, doc, onSnapshot } from "firebase/firestore/lite";
Copied!

My bundle size is now 54.3K (16.9K gzipped), or about 28% the size of the previous bundle. That’s not too shabby if you don’t mind me saying so.

Using Firestore and Firestore Lite Together

Of course there are cases when a one-time data fetch is sufficient, but also cases when real-time updates are needed. Many apps have both of these cases.

Some examples for a one-time fetch might be the text of a blog post or article, or a product description. Within the same page, there might also be a comment section, which you want to refresh as new comments come in, or a number showing how much of the product is still in stock, or there might be another page on the site with a live feed or sports scores.

In these cases, you might want to render the article or product description as quickly as you can, and let the live update elements (comment section, number in stock) load in slower, or (depending on your site layout) when the user navigates to a different page.

Let’s start with the case where the user navigates to a completely new page (a new html file, no SPA trickery) and then move to a method more popular with SPAs - dynamic imports.

Here’s two files, to be code-split by webpack into 2 separate bundles loaded by 2 separate html files. I’ll add the snippet of the webpack config that does this after the app code samples.

Bundle splitting

File 1: This will load when you navigate to index.html. It uses Firestore Lite to get some data. This might be an article page or a product description page. There’s a link at the bottom to navigate to split-2.html. More on that below.

index.js
const app = initializeApp(firebaseConfig);
const firestoreLite = getFirestoreLite(app);
const docRef = doc(firestoreLite, "items/item1");

const docSnapLite = await getDoc(docRef);
renderDataOnPage(docSnapLite.data().field1);
const linkEl = document
  .createElement("a");
linkEl.innerHTML = 'go to a more detailed page';
linkEl.href = '/split-2.html';
document.body.appendChild(linkEl);
Copied!

File 2: This code bundle will be loaded by the aforementioned split-2.html. It uses the standard Firestore SDK to subscribe to a Firestore document using onSnapshot(). This page might have a live feed of some kind (sports scores, chat).

index.js
const app = initializeApp(firebaseConfig);
const firestoreFull = getFirestoreFull(app);
const docRefFull = docFull(firestoreFull, "items/item1");
onSnapshot(docRefFull, (docSnap) => {
  renderDataOnPage(docSnap.data().field1);
});
Copied!

Webpack Config

Here’s the relevant lines in the Webpack config to set these two entry points up:

webpack.config.js
entry: {
      split1: './src/split-1.js',
      split2: './src/split-2.js',
    },
    plugins: [
      new HtmlWebpackPlugin({
        filename: "index.html",
        chunks: ["split1"],
      }),
      new HtmlWebpackPlugin({
        filename: "split-2.html",
        chunks: ["split2"],
      }),]
Copied!

The bundle for split-1.js comes out to 54.4K (16.9K gzipped), while the bundle for split-2.js comes out to 196K (60.9K gzipped). The user experience will be a faster load for the first page, and a slower one for the second.

This might or might not suit your use case. For example, if users arrive on your site through an article link, and the initial article loads too slowly, they might give up and go elsewhere. However, if it loads quickly enough, they might read it, and be willing to navigate around to the comments or other slower-loading parts of the same site, since people tend to be more impatient about the first load of a completely new site where they don’t even know what to expect.

In any case, despite some recent backlash, SPAs and dynamically loaded content are still pretty key to many web apps, so let’s look at how we can dynamically load in the full-featured Firestore SDK without having to go to an entirely new page.

The solution is to use dynamic imports (import()). This can be used either to load real-time content in another section of the same page after loading some data from a one-time fetch, or to load a “new page” in an SPA with real-time data, which is really the same page, because it’s an SPA.

Here’s an example of loading some data with a Firestore Lite getDoc(), and only after that’s fetched and rendered, loading the regular Firestore SDK and subscribing to that same doc.

index.js
const app = initializeApp(firebaseConfig);
const firestoreLite = getFirestoreLite(app);
const docRef = doc(firestoreLite, "items/item1");

const docSnapLite = await getDoc(docRef);
renderDataOnPage(docSnapLite.data().field1);

const {
  getFirestore: getFirestoreFull,
  onSnapshot,
  doc: docFull,
} = await import(
  /* webpackChunkName: "firebase-firestore-dynamic" */
  "firebase/firestore"
);
const firestoreFull = getFirestoreFull(app);
const docRefFull = docFull(firestoreFull, "items/item1");
onSnapshot(docRefFull, (docSnap) => {
  renderDataOnPage(docSnap.data().field1);
});
Copied!

The initial bundle is 58.7K (18.9K) gzipped and the second bundle (loading the full standard Firestore SDK) is 295K. Ok, that’s a lot. And it’s more than it should be, but we’ll get to that in a bit. But first let me point out that only the first bundle needs to load before you can fetch something from Firestore and display it. The second bundle is only needed to begin real time subscription to that data.

So another use case is when both the one-time fetch and the subscription are pointed at the same data source. The one-time fetch is used to get the first load of that data quickly on the page, and then the subscription is dynamically loaded in order to get any subsequent changes to that data. For example, for live sports scores, you might want to render the current score on the page immediately, and then subscribe a few seconds later. It’s unlikely there are going to be any changes in those few seconds unless the game is very exciting, so from the user’s perspective, it looks like they’ve had a live update on the page from first load.

Ok, back to the 295K. I didn’t even calculate how much that is gzipped because it’s clearly unacceptable either way. What happened? The standard Firestore app we started with was only 196K.

Well, the problem is that dynamic imports don’t tree shake. It’s importing all of the code in firebase/firestore, whereas if we were using a static import, Webpack would figure out to exclude any code not needed for getFirestore, onSnapshot, or doc. Fortunately we can still get Webpack to do this with a little workaround.

Creating an export file

We create a file called selected-firestore-exports.js that does nothing except import and then export the 3 named exports we need from firebase/firestore.

selected-firestore-exports.js
export { getFirestore, onSnapshot, doc } from "firebase/firestore";
Copied!

Then we change our dynamic import line to import from that file instead of directly from firebase-firestore.

index.js
const {
    getFirestore: getFirestoreFull,
    onSnapshot,
    doc: docFull,
  } = await import(
    // Previously: "firebase/firestore"
    "./selected-firestore-exports"
  );
Copied!

Webpack can now statically analyze that intermediate file and treeshake out any Firestore code that file doesn’t import. I call this method “preshaking” because you pre-treeshake the import. There is probably a better name for it.

As far as numbers go we’re looking at 57.9K (18.6 gzipped) for the initial bundle (pretty similar to before), and 174K (54.1K gzipped) for the second bundle. That’s much more reasonable. It’s still large, but it’s what you expect for the full-featured Firestore SDK, which is what you’re loading there. And you’ve already put key content on the page from the one-time fetch, so the most important stuff should be there for the user to look at.

How Does This Affect Page Load Times?

So numbers are one thing, but how does this affect the actual user? Here’s how the page loads using the simulated “Slow 3G” mode in Chrome DevTools.

First, using the full Firestore SDK from the beginning:

Using the Chrome DevTools

A screenshot of the Chrome DevTools Network panel showing the information detailed below.
A screenshot of the Chrome DevTools Network panel showing the information detailed below.

On “Slow 3G” speeds, it takes about 10 seconds to see data on the page. (Time for the page to load, for the JS to load, and then for the data to return from Firestore - that’s the third row.)

A breakdown:

  1. First row: initial page request. This will always be about 2s on “Slow 3G”.
  2. Second row: the JS bundle. It’s about 6s.
  3. Third row: the initial data fetch from the Firestore backend, about 2s.
  4. Fourth row: the ongoing WebChannel subscription.

Using Firestore Lite, the JS load time is reduced by a half (from 6s to 3s), which shaves 3 seconds off of waiting for data to appear. (We still have the same 2s for the initial page request and 2s for retrieving data.)

A screenshot of the Chrome DevTools Network panel showing the information detailed below.
A screenshot of the Chrome DevTools Network panel showing the information detailed below.

Of course, that’s for an app with no real-time capability. What about our dynamic import app, which brings in the best of both worlds?

The picture is a little more complex, but you can see the initial page loads in the same amount of time (2 + 3 + 2 seconds to fetched data), and the more leisurely 5s load of the Firestore bundle occurs after the page is “done” (all it adds is real-time subscription to the already loaded data).

A screenshot of the Chrome DevTools Network panel showing the information detailed below.
A screenshot of the Chrome DevTools Network panel showing the information detailed below.

Summary

As all blog posts touting new features should conclude, use your best judgment about the right tools to use. Firestore Lite can be a powerful tool in reducing bundle size, but it comes at the expense of some key Firestore features. There’s ways to use both of them together, but you may need to tinker with your bundler (such as Webpack) or make sure you fully understand how to use dynamic imports. Enjoy!