Supercharge your web app by migrating to the modular Firebase JS SDK

1. Before you begin

The modular Firebase JS SDK is a rewrite of the existing JS SDK and will be released as the next major version. It enables developers to exclude unused code from the Firebase JS SDK to create smaller bundles and achieve better performance.

The most noticeable difference in the modular JS SDK is that features are now organized in free floating functions that you will import, as opposed to in a single firebase namespace that includes everything. This new way of code organization is what allows for tree shaking, and you will learn how to upgrade any app currently using the v8 Firebase JS SDK to the new modular one.

To provide a smooth upgrade process, a set of compatibility packages is provided. In this codelab, you'll learn how to use the compatibility packages to port the app piece by piece.

What you'll build

In this codelab, you're going to gradually migrate an existing stock watchlist web app that uses the v8 JS SDK to the new modular JS SDK in three stages:

  • Upgrade the app to use the compatibility packages
  • Upgrade the app from the compatibility packages to the modular API piece by piece
  • Use Firestore Lite, a lightweight implementation of the Firestore SDK, to further improve the performance of the app

2d351cb47b604ad7.png

This codelab is focused on upgrading the Firebase SDK. Other concepts and code blocks are glossed over and are provided for you to simply copy and paste.

What you'll need

  • A browser of your choice, such as Chrome
  • The IDE/text editor of your choice, such as WebStorm, Atom, Sublime, or VS Code
  • The package manager npm, which typically comes with Node.js
  • The codelab's sample code (See the next step of the codelab for how to get the code.)

2. Get set up

Get the code

Everything you need for this project resides in a Git repo. To get started, you'll need to grab the code and open it in your favorite dev environment.

Clone the codelab's Github repository from the command line:

git clone https://github.com/FirebaseExtended/codelab-modular-sdk.git

Alternatively, if you do not have git installed, you can download the repository as a ZIP file, and unpack the downloaded zip file.

Import the app

  1. Using your IDE, open or import the codelab-modular-sdk directory.
  2. Run npm install to install the dependencies required to build and run the app locally.
  3. Run npm run build to build the app.
  4. Run npm run serve to start the web server
  5. Open a browser tab to http://localhost:8080

71a8a7d47392e8f4.png

3. Establish a baseline

What's your starting point?

Your starting point is a stock watchlist app designed for this codelab. The code has been simplified to illustrate the concepts in this codelab, and it has little error handling. If you choose to reuse any of this code in a production app, make sure that you handle any errors and fully test all code.

Make sure everything works in the app:

  1. Log in anonymously using the login button in the upper right corner.
  2. After logging in, search and add "NFLX", "SBUX" and "T" to the watchlist by clicking the Add button, typing in the letters, and clicking the search result row that pops up below.
  3. Remove a stock from the watchlist by clicking the x at the end of the row.
  4. Watch the real-time updates to the stock price.
  5. Open Chrome DevTools, go to the Network tab and check Disable cache and Use large request rows. Disable cache makes sure we always get the latest changes after a refresh and Use large request rows makes the row display both the transmitted size and the resource size for a resource. In this codelab, we are mainly interested in the size of main.js.

48a096debb2aa940.png

  1. Load the app under different network conditions using simulated throttling. You will be using Slow 3G to measure the load time in this codelab because it is where a smaller bundle size helps the most.

4397cb2c1327089.png

Now jump in and start migrating the app to the new modular API.

4. Use the compatibility packages

The compatibility packages allow you to upgrade to the new SDK version without changing all of the Firebase code at once. You can upgrade them to the modular API gradually.

In this step, you will upgrade the Firebase library from v8 to the new version and change the code to use the compatibility packages. In the following steps, you'll learn how to upgrade only the Firebase Auth code to use the modular API first, then upgrade the Firestore code.

At the end of each step, you should be able to compile and run the app without breakage, and see a decrease in bundle size as we migrate each product.

Get the new SDK

Find the dependencies section in the package.json and replace it with the following:

package.json

"dependencies": {
    "firebase": "^9.0.0" 
}

Reinstall the dependencies

Since we changed the version of the dependency, we need to rerun npm install to get the new version of the dependency.

Change import paths

The compatibility packages are exposed under the submodule firebase/compat, so we will update the import paths accordingly:

  1. Go to file src/firebase.ts
  2. Replace the existing imports with the following imports:

src/firebase.ts

import firebase from 'firebase/compat/app'; 
import 'firebase/compat/auth'; 
import 'firebase/compat/firestore';

Verify app works

  1. Run npm run build to rebuild the app.
  2. Open a browser tab to http://localhost:8080 , or refresh the existing tab.
  3. Play with the app. Everything should still be working.

5. Upgrade Auth to use the modular API

You can upgrade Firebase products in any order. In this codelab, you'll upgrade Auth first to learn the basic concepts because the Auth API is relatively simple. Upgrading Firestore is a little more involved, and you will learn how to do that next.

Update Auth initialization

  1. Go to file src/firebase.ts
  2. Add the following import:

src/firebase.ts

import { initializeAuth, indexedDBLocalPersistence } from 'firebase/auth';
  1. Delete import ‘firebase/compat/auth'.
  2. Replace export const firebaseAuth = app.auth(); with:

src/firebase.ts

export const firebaseAuth = initializeAuth(app, { persistence: [indexedDBLocalPersistence] });
  1. Remove export type User = firebase.User; at the end of the file. User will be directly exported in src/auth.ts which you will change next.

Update Auth code

  1. Go to file src/auth.ts
  2. Add the following imports to the top of the file:

src/auth.ts

import { 
    signInAnonymously, 
    signOut,
    onAuthStateChanged,
    User
} from 'firebase/auth';
  1. Remove User from import { firebaseAuth, User } from './firebase'; since you have already imported User from ‘firebase/auth'.
  2. Update functions to use the modular API.

As you've already seen earlier when we updated the import statement, packages in version 9 are organized around functions that you can import, in contrast to the version 8 APIs which are based on a dot-chained namespace and service pattern. It is this new organization of code that enables tree shaking unused code, because it allows build tools to analyze what code is used and what is not.

In version 9, services are passed as the first argument to the functions. Services are the objects you get from initializing a Firebase service, e.g. the object returned from getAuth() or initializeAuth(). They hold the state of a particular Firebase service, and the function uses the state to perform its tasks. Let's apply this pattern to implement the following functions:

src/auth.ts

export function firebaseSignInAnonymously() { 
    return signInAnonymously(firebaseAuth); 
} 

export function firebaseSignOut() { 
    return signOut(firebaseAuth); 
} 

export function onUserChange(callback: (user: User | null) => void) { 
    return onAuthStateChanged(firebaseAuth, callback); 
} 

export { User } from 'firebase/auth';

Verify app works

  1. Run npm run build to rebuild the app.
  2. Open a browser tab to http://localhost:8080 , or refresh the existing tab
  3. Play with the app. Everything should still be working.

Check bundle size

  1. Open Chrome DevTools.
  2. Switch to the Network tab.
  3. Refresh the page to capture network requests.
  4. Look for main.js and check its size. You have reduced the bundle size by 100KB (36 KB gzipped), or about 22% smaller by changing only a few lines of code! The site is also loading 0.75s faster on a slow 3g connection.

2e4eafaf66cd829b.png

6. Upgrade Firebase App and Firestore to use the modular API

Update Firebase initialization

  1. Go to file src/firebase.ts.
  2. Replace import firebase from ‘firebase/compat/app'; with:

src/firebase.ts

import { initializeApp } from 'firebase/app';
  1. Replace const app = firebase.initializeApp({...}); with:

src/firebase.ts

const app = initializeApp({
    apiKey: "AIzaSyBnRKitQGBX0u8k4COtDTILYxCJuMf7xzE", 
    authDomain: "exchange-rates-adcf6.firebaseapp.com", 
    databaseURL: "https://exchange-rates-adcf6.firebaseio.com", 
    projectId: "exchange-rates-adcf6", 
    storageBucket: "exchange-rates-adcf6.appspot.com", 
    messagingSenderId: "875614679042", 
    appId: "1:875614679042:web:5813c3e70a33e91ba0371b"
});

Update Firestore initialization

  1. In the same file src/firebase.ts, replace import 'firebase/compat/firestore'; with

src/firebase.ts

import { getFirestore } from 'firebase/firestore';
  1. Replace export const firestore = app.firestore(); with:

src/firebase.ts

export const firestore = getFirestore();
  1. Remove all lines after "export const firestore = ..."

Update imports

  1. Open file src/services.ts.
  2. Remove FirestoreFieldPath, FirestoreFieldValue and QuerySnapshot from the import. The import from './firebase' should now look like the following:

src/services.ts

import { firestore } from './firebase';
  1. Import the functions and types you're going to use at the top of the file:
    **src/services.ts**
import { 
    collection, 
    getDocs, 
    doc, 
    setDoc, 
    arrayUnion, 
    arrayRemove, 
    onSnapshot, 
    query, 
    where, 
    documentId, 
    QuerySnapshot
} from 'firebase/firestore';
  1. Create a reference to the collection that contains all tickers:

src/services.ts

const tickersCollRef = collection(firestore, 'current');
  1. Use getDocs()to fetch all documents from the collection:

src/services.ts

const tickers = await getDocs(tickersCollRef);

See search() for the finished code.

Update addToWatchList()

Use doc() to create a document reference to user's watchlist, then add a ticker to it using setDoc() with arrayUnion():

src/services.ts

export function addToWatchList(ticker: string, user: User) {
      const watchlistRef = doc(firestore, `watchlist/${user.uid}`);
      return setDoc(watchlistRef, {
       tickers: arrayUnion(ticker)
   }, { merge: true });
}

Update deleteFromWatchList()

Similarly, remove a ticker from user's watchlist using setDoc() with arrayRemove():

src/services.ts

export function deleteFromWatchList(ticker: string, user: User) {
   const watchlistRef = doc(firestore, `watchlist/${user.uid}`);
   return setDoc(watchlistRef, {
       tickers: arrayRemove(ticker)
   }, { merge: true });
}

Update subscribeToTickerChanges()

  1. Use doc() to create a document reference to user's watchlist first, then listen to watchlist changes using onSnapshot():

src/services.ts

const watchlistRef = doc(firestore, `watchlist/${user.uid}`);
const unsubscribe = onSnapshot(watchlistRef, snapshot => {
   /* subscribe to ticker price changes */
});
  1. Once you have the tickers in the watchlist, use query() to create a query to fetch their prices and use onSnapshot()to listen to their price changes:

src/services.ts

const priceQuery = query(
    collection(firestore, 'current'),
    where(documentId(), 'in', tickers)
);
unsubscribePrevTickerChanges = onSnapshot(priceQuery, snapshot => {
               if (firstload) {
                   performance && performance.measure("initial-data-load");
                   firstload = false;
                   logPerformance();
               }
               const stocks = formatSDKStocks(snapshot);
               callback(stocks);
  });

See subscribeToTickerChanges() for the complete implementation.

Update subscribeToAllTickerChanges()

First you will use collection() to create a reference to the collection that contains prices for all tickers first, then use onSnapshot() to listen to price changes:

src/services.ts

export function subscribeToAllTickerChanges(callback: TickerChangesCallBack) {
   const tickersCollRef = collection(firestore, 'current');
   return onSnapshot(tickersCollRef, snapshot => {
       if (firstload) {
           performance && performance.measure("initial-data-load");
           firstload = false;
           logPerformance();
       }
       const stocks = formatSDKStocks(snapshot);
       callback(stocks);
   });
}

Verify app works

  1. Run npm run build to rebuild the app.
  2. Open a browser tab to http://localhost:8080 , or refresh the existing tab
  3. Play with the app. Everything should still be working.

Check bundle size

  1. Open Chrome DevTools.
  2. Switch to the Network tab.
  3. Refresh the page to capture network requests.
  4. Look for main.js and check its size. Compare it to the original bundle size again - we have reduced the bundle size by over 200KB (63.8 KB gzipped), or 50% smaller, which translates to 1.3s faster load time!

7660cdc574ee8571.png

7. Use Firestore Lite to speed up initial page rendering

What is Firestore Lite?

The Firestore SDK offers complex caching, real-time streaming, persistent storage, multi-tab offline sync, retries, optimistic concurrency, and so much more, and therefore is quite large in size. But you might simply want to get the data once, without needing any of the advanced features. For those cases, Firestore created a simple and light solution, a brand new package — Firestore Lite.

One great use case for Firestore Lite is optimizing the performance of the initial page rendering, where you only need to know whether or not a user is logged in, then read some data from Firetore to display.

In this step, you will learn how to use Firestore lite to reduce the bundle size to speed up the initial page rendering, then load the main Firestore SDK dynamically to subscribe to real-time updates.

You will refactor the code to:

  1. Move real-time services to a separate file, so they can be dynamically loaded using dynamic import.
  2. Create new functions to use Firestore Lite to retrieve watchlist and stock prices.
  3. Use the new Firestore Lite functions to retrieve data to make the initial page rendering, then dynamically load the real-time services to listen to real-time updates.

Move real-time services to a new file

  1. Create a new file called src/services.realtime.ts.
  2. Move the functions subscribeToTickerChanges() and subscribeToAllTickerChanges() from src/services.ts into the new file.
  3. Add necessary imports to the top of the new file.

You still need to make a couple of changes here:

  1. First, create a Firestore instance from the main Firestore SDK at the top of the file to be used in the functions. You can't import the Firestore instance from firebase.ts here because you are going to change it to a Firestore Lite instance in a few steps, which will be used only for the initial page rendering.
  2. Second, get rid of the firstload variable and the if block guarded by it. Their functionalities will be moved to new functions you will create in the next step.

src/services.realtime.ts

import { User } from './auth'
import { TickerChange } from './models';
import { collection, doc, onSnapshot, query, where, documentId, getFirestore } from 'firebase/firestore';
import { formatSDKStocks } from './services';

const firestore = getFirestore();
type TickerChangesCallBack = (changes: TickerChange[]) => void

export function subscribeToTickerChanges(user: User, callback: TickerChangesCallBack) {

   let unsubscribePrevTickerChanges: () => void;

   // Subscribe to watchlist changes. We will get an update whenever a ticker is added/deleted to the watchlist
   const watchlistRef = doc(firestore, `watchlist/${user.uid}`);
   const unsubscribe = onSnapshot(watchlistRef, snapshot => {
       const doc = snapshot.data();
       const tickers = doc ? doc.tickers : [];

       if (unsubscribePrevTickerChanges) {
           unsubscribePrevTickerChanges();
       }

       if (tickers.length === 0) {
           callback([]);
       } else {
           // Query to get current price for tickers in the watchlist
           const priceQuery = query(
               collection(firestore, 'current'),
               where(documentId(), 'in', tickers)
           );

           // Subscribe to price changes for tickers in the watchlist
           unsubscribePrevTickerChanges = onSnapshot(priceQuery, snapshot => {
               const stocks = formatSDKStocks(snapshot);
               callback(stocks);
           });
       }
   });
   return () => {
       if (unsubscribePrevTickerChanges) {
           unsubscribePrevTickerChanges();
       }
       unsubscribe();
   };
}

export function subscribeToAllTickerChanges(callback: TickerChangesCallBack) {
   const tickersCollRef = collection(firestore, 'current');
   return onSnapshot(tickersCollRef, snapshot => {
       const stocks = formatSDKStocks(snapshot);
       callback(stocks);
   });
}

Use Firestore lite to fetch data

  1. Open src/services.ts.
  2. Change the import path from ‘firebase/firestore' to ‘firebase/firestore/lite', add getDoc and remove onSnapshot from the import list:

src/services.ts

import { 
    collection, 
    getDocs, 
    doc, 
    setDoc, 
    arrayUnion, 
    arrayRemove,
//  onSnapshot, // firestore lite doesn't support realtime updates
    query, 
    where, 
    documentId, 
    QuerySnapshot, 
    getDoc // add this import
} from 'firebase/firestore/lite';
  1. Add functions to fetch data needed for the initial page rendering using Firestore Lite:

src/services.ts

export async function getTickerChanges(tickers: string[]): Promise<TickerChange[]> {

   if (tickers.length === 0) {
       return [];
   }

   const priceQuery = query(
       collection(firestore, 'current'),
       where(documentId(), 'in', tickers)
   );
   const snapshot = await getDocs(priceQuery);
   performance && performance.measure("initial-data-load");
   logPerformance();
   return formatSDKStocks(snapshot);
}

export async function getTickers(user: User): Promise<string[]> {
   const watchlistRef = doc(firestore, `watchlist/${user.uid}`);
   const data =  (await getDoc(watchlistRef)).data();

   return data ? data.tickers : [];
}

export async function getAllTickerChanges(): Promise<TickerChange[]> {
   const tickersCollRef = collection(firestore, 'current');
   const snapshot = await getDocs(tickersCollRef);
   performance && performance.measure("initial-data-load");
   logPerformance();
   return formatSDKStocks(snapshot);
}
  1. Open src/firebase.ts, and change the import path from ‘firebase/firestore' to ‘firebase/firestore/lite':

src/firebase.ts

import { getFirestore } from 'firebase/firestore/lite';

Tie them all together

  1. Open src/main.ts.
  2. You will need the newly created functions to fetch data for the initial page rendering, and a couple of helper functions to manage the app state. So now update the imports:

src/main.ts

import { renderLoginPage, renderUserPage } from './renderer';
import { getAllTickerChanges, getTickerChanges, getTickers } from './services';
import { onUserChange } from './auth';
import { getState, setRealtimeServicesLoaded, setUser } from './state';
import './styles.scss';
  1. Load src/services.realtime using a dynamic import at the top of the file. Variable loadRealtimeService is a promise which will resolve with the real-time services once the code is loaded. You will use it later to subscribe to real-time updates.

src/main.ts

const loadRealtimeService = import('./services.realtime');
loadRealtimeService.then(() => {
   setRealtimeServicesLoaded(true);
});
  1. Change the callback of onUserChange() to an async function, so that we can use await in the function body:

src/main.ts

onUserChange(async user => {
 // callback body
});
  1. Now fetch the data to make the initial page rendering using the new functions we created in the earlier step.

In the onUserChange() callback, find the if condition where a user is logged in, and copy & paste the code inside the if statement:

src/main.ts

onUserChange(async user => {
      // LEAVE THE EXISTING CODE UNCHANGED HERE
      ...

      if (user) {
       // REPLACE THESE LINES

       // user page
       setUser(user);

       // show loading screen in 500ms
       const timeoutId = setTimeout(() => {
           renderUserPage(user, {
               loading: true,
               tableData: []
           });
       }, 500);

       // get data once if realtime services haven't been loaded
       if (!getState().realtimeServicesLoaded) {
           const tickers = await getTickers(user);
           const tickerData = await getTickerChanges(tickers);
           clearTimeout(timeoutId);
           renderUserPage(user, { tableData: tickerData });
       }

       // subscribe to realtime updates once realtime services are loaded
       loadRealtimeService.then(({ subscribeToTickerChanges }) => {
           unsubscribeTickerChanges = subscribeToTickerChanges(user, stockData => {
               clearTimeout(timeoutId);
               renderUserPage(user, { tableData: stockData })
           });
       });
   } else {
     // DON'T EDIT THIS PART, YET   
   }
}
  1. In the else block where no user is logged in, fetch price info for all stocks using firestore lite, render the page, then listen to price changes once real-time services are loaded:

src/main.ts

if (user) {
   // DON'T EDIT THIS PART, WHICH WE JUST CHANGED ABOVE
   ...
} else {
   // REPLACE THESE LINES

   // login page
   setUser(null);

   // show loading screen in 500ms
   const timeoutId = setTimeout(() => {
       renderLoginPage('Landing page', {
           loading: true,
           tableData: []
       });
   }, 500);

   // get data once if realtime services haven't been loaded
   if (!getState().realtimeServicesLoaded) {
       const tickerData = await getAllTickerChanges();
       clearTimeout(timeoutId);
       renderLoginPage('Landing page', { tableData: tickerData });
   }

   // subscribe to realtime updates once realtime services are loaded
   loadRealtimeService.then(({ subscribeToAllTickerChanges }) => {
       unsubscribeAllTickerChanges = subscribeToAllTickerChanges(stockData => {
           clearTimeout(timeoutId);
           renderLoginPage('Landing page', { tableData: stockData })
       });
   });
}

See src/main.ts for the finished code.

Verify app works

  1. Run npm run build to rebuild the app.
  2. Open a browser tab to http://localhost:8080 , or refresh the existing tab.

Check bundle size

  1. Open Chrome Devtools.
  2. Switch to the Network tab.
  3. Refresh the page to capture network requests
  4. Look for main.js and check its size.
  5. Now it's only 115KB (34.5KB gzipped). That is 75% smaller than the original bundle size which was 446KB(138KB gzipped)! As a result, the site is loading over 2s faster on 3G connection - a great performance and user experience improvement!

9ea7398a8c8ef81b.png

8. Congratulations

Congratulations, you successfully upgraded the app and made it smaller and faster!

You used the compat packages to upgrade the app piece by piece, and you used Firestore Lite to speed up initial page rendering, then dynamically load the main Firestore to stream price changes.

You also reduced the bundle size and improved its load time over the course of this codelab:

main.js

resource size (kb)

gzipped size (kb)

load time (s) (over slow 3g)

v8

446

138

4.92

v9 compat

429

124

4.65

v9 only modular Auth

348

102

4.2

v9 fully modular

244

74.6

3.66

v9 fully modular + Firestore lite

117

34.9

2.88

32a71bd5a774e035.png

You now know the key steps required to upgrade a web app that uses v8 Firebase JS SDK to use the new modular JS SDK.

Further reading

Reference docs