Blog

How to build a serverless bundle size monitor for free

πŸ‘‹ Introduction

Keeping a slim bundle size for our Chrome extension is a big priority for us at Text Blaze. Smaller code leads to a faster user experience. We hand write all the code, and we often manually vendor any dependencies, stripping them down to what's necessary. However, even we were caught off-guard recently when our bundle size was inflated by over 300KB! This increase had come from a long import chain that accidentally imported FirebaseJS.

In this blog post, we will show how we future-proofed ourselves against unintentional increases in bundle size. We implemented a bundle size monitor, that monitors the bundle size in each commit and flags any unusual deviation. It is completely free to use, serverless, and less than 200 lines of code. Read on to know more!

A panda maintaining its weight: symbolic of a codebase maintaining its bundle size
DALLΒ·E 3's vision of a panda regulating its weight,
symbolic of an app limiting its bundle size

πŸ›’ Market research

We checked some existing tools, but they did not work for us. For example:

  1. GitLab has Metrics Reports, but they're very primitive.
  2. GitHub Marketplace has a compressed-size-action plugin - but it is costly because it builds both the target and source branches on every PR. For a build that takes 300 seconds, with 50 pull requests in a month, this costs an additional 250 CI minutes per month.
  3. Bundlemon is good, but it is very huge, and its self-hosted version requires a dedicated server with MongoDB. This adds extra maintenance burden.
    For comparison, our tool runs with less than 200 lines of code and is serverless.

We then decided to build our own in-house bundle size monitor.

πŸ“Š Maintaining history across commits

We need to record the size history of all bundles built from the target branch. We write this history to Data Blaze, our online database with an easy-to-use SQL-like API.

Here's an example of how the history looks:

You can structure this differently. Primarily, we need only four columns: CommitHash, CommitTimestamp, AppType, and ByteSize.

πŸ” Reading from the spreadsheet

In every build pipeline, we need to read the bundle size for the main branch and compare it to the current build's bundle size. We can write a NodeJS script to do this.

Note: these code samples are a rough guide to get you started. queryDataBlaze is implemented at the end of this blog post.

const fs = require('fs');

/// CONFIGURE START
// total size of your bundle
const newBundleSize = fs.statSync('your-bundle.zip').size;
// your bundle's name (should be in the AppType column)
const TYPE = "Dashboard";
// minimum increase in bytes for which to fail the build
const LIMIT_BYTES = 5000;
// your Data Blaze create/read token (should store in CI)
const TOKEN = "yourtoken";
// the ID of your Data Blaze space
const SPACE_ID = "your_space_id";
/// CONFIGURE END

async function main() {
  // Query the spreadsheet using a familiar SQL-like syntax
  const previousBundleSize = await queryDataBlaze(`
    SELECT ByteSize
    FROM BundleSizes
    WHERE Type = "${TYPE}"
      AND BranchName = "${process.env.CI_DEFAULT_BRANCH}"
    ORDER BY CommitTimestamp DESC LIMIT 1
  `).results[0].ByteSize;

  const sizeIncreaseBytes = newBundleSize - previousBundleSize;
  const isTooBig = sizeIncreaseBytes > LIMIT_BYTES;

  console.log(`πŸ›  New bundle size: ${newBundleSize} bytes,
and previous bundle size: ${previousBundleSize} bytes 
(increased by ${sizeIncreaseBytes} bytes)`);

  if (isTooBig) {
    console.log(`⚠️ WARNING: New bundle is too large for ${TYPE}`);
    process.exit(1); // fail this CI job
  } else {
    console.log("Bundle size not too large, continuing...");
  }
}

main();

πŸ’¬ Chat notifications for bundle size changes

It would be nice to celebrate whenever the bundle size decreases significantly πŸ˜„ We can send a message in the group chat with credits to the engineer, like in this image:

Google Chat screenshot showing bundle size decrease

Let us implement this. As with every other CI/CD setup, we run a build job on the main branch for every new commit. In this build job, we measure the bundle size and then insert this size as a new row into the table. This is done like so:

// ...previous code
async function createDataBlazeRow(status) {
  const query = `
    INSERT INTO Table  
    SET CommitHash="${process.env.CI_COMMIT_SHA}",
        CommitTimestamp="${process.env.CI_COMMIT_TIMESTAMP}",
        ByteSize=${newBundleSize},
        Type="${TYPE}",
        BranchName="${process.env.CI_BRANCH_NAME}",
        Status="${status}",
        IncreaseInSize=${size_increase_bytes},
        User="${GITLAB_USER_LOGIN}"
  `;

  const rowID = await queryDataBlaze(query);  
  return rowID;  
}
createDataBlazeRow("DefaultBranch")

We then trigger a webhook for every new row created in the table. This pings a web app running on Google Apps Script. For a large bundle size decrease, this web app notifies our team in our Google Chat app! (exact implementation is in the footnote below)

Success kid meme

🏁 Conclusion

In this blog, we have shown how you can implement your own customizable, serverless bundle size monitoring tool in just a few lines of code that are also easy to maintain.

Credits: Thanks to Scott, Obed and Dan for reviewing a draft of this post.

Footnote and implementation details
  1. As mentioned above, the import chain that led to Firebase was very difficult to find. Finally, we did a git bisect to locate the exact commit that introduced this import chain.
  2. The variables prefixed CI_ are environment variables specific to GitLab pipelines. They may vary for your hosting provider.
  3. queryDataBlaze function is a standard HTTP post, given below:
async function queryDataBlaze(query) {
  const response = await fetch(
`https://data-api.blaze.today/api/database/${SPACE_ID}/query/`, {
    method: 'POST',
    headers: {
      'Authorization': `Token ${TOKEN}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ query }),
  });

  const data = await response.json();
  return data;
}
  1. Google Apps Script provides a generous free tier. You may use another provider of your choice with almost the same code.
  2. Here's an example Apps Script implementation to send the chat messages:
/// CONFIGURE START
// Your chat message send URL (Slack/Google Chat/etc.)
const PROD_URL = 'https://chat.googleapis.com/v1/spaces/...';
// Minimum size decrease in bytes when you'd like to notify users
const BYTE_SIZE_DEC_LIMIT = 2000;
// Your repository's default branch name
const DEFAULT_BRANCH = 'main';
/// CONFIGURE END

// Triggered on every new row created
function doPost(e) {
  const data = JSON.parse(e.postData.contents);
  const numberFormatter = Intl.NumberFormat('en');

  function handleRowCreate(row) {
    const sizeIncrease = Number.parseInt(row.IncreaseInSize, 10);
    const hasDecreased = sizeIncrease < 0 &&
      Math.abs(sizeIncrease) >= BYTE_SIZE_DEC_LIMIT;

    if (!hasDecreased) {
      return;
    }
    const branchName = row.BranchName;

    // only notify for changes on the main branch
    if (hasDecreased && branchName !== DEFAULT_BRANCH) {
      return;
    }

    const appType = row.Type.value;
    const delta = numberFormatter.format(-sizeIncrease);
    const msg = `πŸŽ‰ Bundle size decreased πŸŽ‰ thanks to ${row.User}!

This commit decreased the size for ${appType} by ${delta} bytes.

Commit: https://your-repository-url.com/-/commit/${row.CommitHash}`;

    const options = {
      method : "post",
      contentType : "application/json",
      payload : JSON.stringify({
        // NOTE: adjust this payload as per your chat application
        'text': msg
      })
    };

    UrlFetchApp.fetch(PROD_URL, options);
  }

  for (const row of data.items) {
    handleRowCreate(row);
  }

  const response = JSON.stringify({ status: "success", data });
  return ContentService.createTextOutput(response)
    .setMimeType(ContentService.MimeType.JSON);
}

Psst...

Did you like this blog post? Are you interested in working in this domain? We're hiring: hiring@blaze.today ;)