David East - Author
Web development, Firebase, and Productivity articles

I dropped 95% of my Firebase bundle size using this one weird trick

I've said this before, but for real, I'm starting a newsletter! One-topic covered in-depth each week, plus a bunch of links on the latest in frontend development topics. Sign up here!

Welcome to the article.

I'm glad you made it past the click bait-y title. I have to admit, I lied a little. I did drop 95% of my JavaScript bundle, but it didn't exactly disappear. I also needed more than just one weird trick. Let's start from the top.

Firebase 🔥

I'm a Developer Advocate for Google on the Firebase team. I have been for over 4 years, an eternity in the tech industry. We have this cloud database called Firestore. Firebase hosts the database and you interface with it using our JavaScript library. It's awesome. Firestore synchronizes data in realtime and works fully offline. Fully. Offline.

These features make Firestore a great tool for building apps on the web. However, there is one problem. It's not exactly the lightest JavaScript library around.

This is Webpack's fault

This all started when I was building a Firestore app with Webpack. It's a super rad realtime restaurant table tracker: table-tracer.firebaseapp.com. I started off with a basic Preact setup. This lead to a nice a tiny JavaScript bundle.

Version: webpack 4.6.0
Time: 2946ms
Built at: 2018-05-07 07:06:37
    Asset       Size  Chunks                    Chunk Names
    bundle.js   12.3 KiB       0  [emitted]         main

Only 12.3kb (4.5kb gzipped!)? Nice. But then I added Firestore.

Webpack has this nifty little notification that informs you if any JavaScript asset is over 244kb in size. Why 244kb? If you rely on 244kb+ of JavaScript to load before the page can render, you're going to have a bad time. And here I sat looking at the following notification:

WARNING in asset size limit: The following asset(s) exceed the recommended size limit (244 KiB).
This can impact web performance.
Assets:
  bundle.js (311 KiB)

Adding Firestore increased my bundle size nearly 300kbs. Webpack was right. This did impact web performance. I was about to have a bad time. Thanks, Webpack.

Poor page load performance

Go a head. Click play. Feel the slowness of my realtime restaurant capacity tracker.

7.1 seconds to load. Ouch. If this were "The Shot", it would have been game over.

Why is it so slow? For starters, this trace was done on a MotoG4 on a "Slow3G" connection using WebpageTest. But the real reason? The app can't render until the JavaScript bundle downloads, parses, and executes. This is disastrous on slow connections. Look at the waterfall chart.

Firestore page load performance trace

The problem is how I'm loading the app. The user has to wait for the JavaScript bundle 311kbs (86.5 kbs gzipped) and the subsequent network requests for the app to load.

This made me sad. Firestore gave me realtime and offline. Realtime made my app interesting, offline made it useful. But these features come at a cost. It takes a lot of bytes of code to implement a persistent connection with a server, state synchronization across devices, persisting state offline, and synchronizing state back the server when online once again.

How can we build modern applications on the web when modern features are too costly for performance?

Progressive bootstrapping

It's not always possible to ship less code. But we can try to ship less code upfront.

Break the app out into small layers. Each layer of the app provides use to the user. The app becomes more useful as each feature loads. This is much better then waiting for the entire app load to be of any use to the user. This concept is known as progressive bootstrapping.

Addy Osmani put it best in his Cost of JavaScript article:

Progressive Bootstrapping may be a better approach. Send down a minimally functional page (composed of just the HTML/JS/CSS needed for the current route). As more resources arrive, the app can lazy-load and unlock more features.

Progressive Bootstrapping visual

At 1.8 seconds I could have rendered content. Instead I had to wait 5.3 more seconds to get data back from Firestore. What if my app didn't need to wait for Firestore to load? I could load my app in smaller pieces. The features enhance the app when they load. Static content first, JavaScript interactivity next, and lastly load offline and realtime to put the icing on the cake.

I'm going to need more than one trick to improve the performance of this app. It's time to break things up.

Trick 1: Create a minimally functional page

Server side rendering is available in modern JavaScript frameworks. Preact makes server side rendering easy with preact-render-to-string. I use the same components on the client to create a server rendered version of the app.

Progressive Bootstrapping visual

A server side rendered app needs three things: HTML rendered from initial data, embedded critical CSS, and initial data transferred as a JSON object.

The HTML is available for immediate rendering. The browser renders the embedded CSS without loading a stylesheet. The JavaScript framework uses the initial JSON to provide functionality before the network data loads from Firestore.

Server side rendering code

Watch the app load with server side rendering implemented.

The content appears to load in just 2.3 seconds! Sadly, there's more to the story.

Server side rendering isn't a silver bullet for increasing performance. What server side rendering gives you is a faster first paint of your content. However, the JavaScript framework is not yet ready. The app still isn't usable until the JavaScript is downloaded, executed, and parsed. It just appears to be.

Server side app trace

The app has content at 2.3 seconds, which is great! But look at those red blocks at the bottom. Those are times where the main thread becomes too busy to process any user input. The result is a frozen user interface. The app isn't fully interactive until around 9.2 seconds. This isn't better than before and on lower powered devices this version might be worse.

There's still room for improvement even if you're okay with the frozen thread. It took 4.5 seconds for the browser to download, parse, and execute the JavaScript bundle. This is where I need to add another layer. This is where I can drop my Firestore out of my JavaScript bundle.

Trick 2: Firestore in a Web Worker

This is the moment you've been waiting for. How did I drop 95% of JavaScript bundle? Well... I put it in another bundle. Preact, Firestore, and my own app code add up to 86.5kbs of gzipped JavaScript. Firestore is the bulk of that weight.

Firestore is 95% of the JavaScript bundle size. Preact is only 5%.

Removing Firestore drops the gzipped bundle to 4.5kbs. That's a 95% reduction in bundle size! How can I remove Firestore without removing the features? Web Workers.

Web Workers

Web Workers are scripts that run in background threads. They are useful because they give you another thread to perform operations in. When your code clogs the main thread, it might help to move that code to a worker. This can help free up the main thread and keep the app interactive.

index.ts
const worker = new Worker('worker.js');
worker.addEventListener('message', event => {
  console.log(event.data, 'Message from the worker!');
});

worker.js
let count = 0;
// sent a message to the main thread every second!
// Hey, this is kinda like syncing data from Firestore 🤔
setInterval(() => {
  count = count + 1;
  // send message to main thread
  self.postMessage({ event: 'count', count });
}, 1000);

In the example above the main thread recieves a message every second from the worker. This is exactly the type of synchronization I need from Firestore.

There are two main benefits to using Web Workers with Firestore.

The first is the processing work in another thread. The server side rendered app freezes at 9.1 seconds when Firestore data loads and Preact has to render new changes. We can free up the thread by off-loading Firestore processing to a worker.

The second is the async loading of Firestore. The worker script isn't a part of the bundle because it loads asynchronously. The worker object on the main thread acts as a proxy to the worker thread. I can start to post messages to the worker thread to act on before it loads.

A large drawback is managing workers. Web Workers are highly unstructured and always async. It is really difficult to manage work through one postMessage method and listener.

worker.js
importScripts('/firebase-bundle.js');

let app = null;
let firebase = firebaseBundle.firebase;

function createCollection(path) {
  return app.firestore().collection(path)
}

// Listen to commands from the main thread and process them here
// in the worker. Magic 💫
self.addEventListener('message', event => {
  switch(event.data.cmd) {
    case 'initializeApp':
      app = firebase.initializeApp(event.data.data);
      const firestore = firebase.firestore();
      const settings = { timestampsInSnapshots: true };
      firestore.settings(settings);
      break;
    case 'firestore.col.add': {
      const { data, path } = event.data.data;
      createCollection(path).add(data);
      break;
    }
    case 'firestore.col.onSnapshot': {
      const path = event.data.data.path;
      createCollection(path).onSnapshot(snap => {
        const docs = snap.docs.map(doc => {
          return {
            id: doc.id,
            data: doc.data(),
            exists: doc.exists,
            metadata: {
              fromCache: doc.metadata.fromCache,
              hasPendingWrites: doc.metadata.hasPendingWrites,
            },
          };
        });
        const size = snap.size;
        const empty = snap.empty;
        self.postMessage({
          name: `firestore.col.${path}.onSnapshot`,
          response: { 
            data: { docs, size, empty }, 
            type: 'QuerySnapshot',
          }
        });
      });
      break;
    }
  }
}, false);

The worker above loads Firestore with the importScripts() method. It then responds to messages from the main thread and kicks off Firestore work and posts messages back to the main thread. I attempted to mirror the Firestore API to provide a familar experience. This technique worked out well since a lot of the Firestore API is asynchronous. However, data over postMessage() must be serializable. This means no functions. This means I had to do some ugly coercing on the main thread.

index.ts
export class FireWorker {
  worker: Worker;
  listeners: ListenerHash = {};
  constructor() { 
    this.worker = new Worker('./worker.js');
    this.worker.addEventListener('message', event => {
      let data = event.data.response.data;
      if(event.data.response.type === 'QuerySnapshot') {
        data.docs = data.docs.map(d => {
          const _data = d.data;
          d.data = () => _data
          return d;
        });
      }
      this.listeners[event.data.name](data);
    });
  }

  initializeApp(opts: any) {
    this.worker.postMessage({ cmd: 'initializeApp', data: opts });
    return new FireWorkerApp(this);
  }

  postMessage(message: PostMessage) {
    this.worker.postMessage(message);
  }

  registerListener(namespace: string, callback: any) {
    this.listeners[namespace] = callback;
  }
}

This was some ugly work, but it allowed me to use my existing Firestore API. This was just a snippet too. You can see the full source here.

There's one lesson for writing Web Worker code. Stand on the shoulders of those who came before you. Libraries like Comlink and workerize simplify your worker code.

Firestore is now it's own worker bundle and Preact is only responsible for the view.

Now my app is split into separate layers for view code and data code. The main thread is responsible for processing only 5% of my total JavaScript bundle. The worker code processes the other 95% percent. The app will boot up quickly and become functional with the server transferred JSON. The realtime and offline features gracefully load when Firestore finally loads in the worker. The app can get to interactive a lot faster with this smaller bundle.

Take a look at the page load with Firestore in a Web Worker.

The app renders content in 1.9 seconds, similar to before. There's just one big difference. The main thread hums along, nice and easy.

Trace with Workers and SSR. Main thread is frozen briefly at first and then is fine.

The main thread is frozen while the JavaScript bundle parses and executes, but this is only for 300ms up front. Afterwards it's smooth sailing.

While this progressively bootstrapped app loads content faster and gets to interactive faster, there are still some draw backs.

The original version of the app took 7.1 seconds to load to a useful state. The progressively loaded version provides the user with state content and interactivity in 2.5 seconds. However, the realtime and offline won't load in the progressive version until around 9 seconds. That's 2 seconds slower than the original version. The compromise is delaying advanced features and prioritizing core content.

I could have tried using HTTP/2 push or link[rel="preload"] tags for the Web Worker and Firestore code. This would have helped them load faster and decreased the delay of the realtime and offline features. However, HTTP/2 push is a finicky beast and that fight is for another blog post.

Think progressively

So yes, I lied. I didn't remove 95% of JavaScript code. But, I did split the code up in a way the browser could digest and quickly delivered a useful app to the user.

Web development will always be a battle between features and performance. It's up to us to find that compromise.

Want more content like this? I'm starting a newsletter! Sign up here!