Cover image for my tree shaking blog post.

🍃 DIY CSS Tree Shaking

Ever wonder how css frameworks like Tailwind output small css bundles? You can create small css bundles yourself with the help of a process called "Tree Shaking". In this blog post I describe how I cut my bundle size in half after I implemented tree shaking with less than 25 lines of code.

by yours truly

CSS frameworks like Bootstrap or Tailwind are excellent off-the-shelf solutions for styling websites.

Some CSS frameworks can even reduce the size of your CSS bundle with tree shaking. Tree shaking is a build-time process that optimises your CSS bundles by removing unused styles.

Tailwind, a "utility-first" CSS framework, provides tree shaking out-of-the-box. This feature helps developers move quickly by avoiding the need to write custom classes for each element.

Utility-first refers to how Tailwind provides CSS utilities (e.g., text-blue-500) that can be assembled to render a design inside of HTML markup, as opposed to writing custom classes with semantic naming like card or banner.

Under the hood, Tailwind uses postcss to filter out styles that you are not using.

Where possible, I have planned to write my own HTML, CSS, and JS for my personal website. What follows is my research and integration of tree shaking tactics and how I integrated this tactic into my website's build system.


Benchmarking

The browser provides a few ways to evaluate the bundled size of stylesheets. In my case, I used these features as a means to benchmark against tree shaking.

The bundled size of stylesheets can be inspected using the Network tab in a chromium-based browser. Type ctrl+shift+i and click on the tab named "Network". Next we need to open the Coverage drawer which will show information about code usage. Type ctrl+shift+p to open the command input box. Type "coverage" and select the first option. In the below screenshot, I also filted by "assets" so that I could inspect just the styles deployed on my website:

A few notable observations:

  1. Unused styles: The coverage tab shows how much of each resource is used on the current page. Notice how the "base" stylesheet has a high percentage of unused styles.
  2. Bundle size: Both the network and computed tab show the size for each resource delivered.

The high percentage of unused styles in the "base" stylesheet should be expected. This is because my base stylesheet provides common utilities and style modules that are used across the site.

One way to reduce base stylesheets is to identify classes or utilities that exist on only one page and move those classes to a unique stylesheet for that page.

However in this blog post, I am not concerned about the unused style percentage; it is OK to have some unused styles in the base stylesheet because not every page will use that entire stylesheet. Instead my goal is to reduce the size of the bundle, as there are likely classes that can be removed that are not used on any page at all. So bundle size will be my benchmark to measure tree-shaking tools against in the remainder of this blog post.

Before 🌳 After 🍃 Difference
Stylesheet (Bytes) (Bytes) (Bytes)
base.scss 20,780 --- ---
blog.scss 712 --- ---
index.scss 227 --- ---
TOTAL: 21,719 --- ---

Sources of Bloat

The biggest source of the unused styles are due to custom utility functions. For example, consider the below sass function from my codebase:


.margin {
  @each $i in $spacing {
    &-#{$i} {
      margin: #{$i}px !important;
    }
  }
  $edges: (top, bottom, left, right);
  @each $e in $edges {
    &-#{$e} {
      @each $i in $spacing {
        &-#{$i} {
          margin-#{$e}: #{$i}px !important;
        }
      }
    }
  }
  &-tb {
    @each $i in $spacing {
      &-#{$i} {
        margin-top: #{$i}px !important;
        margin-bottom: #{$i}px !important;
      }
    }
  }
  &-lr {
    @each $i in $spacing {
      &-#{$i} {
        margin-left: #{$i}px !important;
        margin-right: #{$i}px !important;
      }
    }
  }
}
  

This function will generate the following styles at build time:


.margin-0{margin:0px !important}.margin-5{margin:5px !important}.margin-8{margin:8px !important}.margin-10{margin:10px !important}.margin-12{margin:12px !important}.margin-15{margin:15px !important}.margin-16{margin:16px !important}.margin-18{margin:18px !important}.margin-20{margin:20px !important}.margin-25{margin:25px !important}.margin-30{margin:30px !important}.margin-35{margin:35px !important}.margin-40{margin:40px !important}.margin-45{margin:45px !important}.margin-50{margin:50px !important}.margin-top-0{margin-top:0px !important}.margin-top-5{margin-top:5px !important}.margin-top-8{margin-top:8px !important}.margin-top-10{margin-top:10px !important}.margin-top-12{margin-top:12px !important}.margin-top-15{margin-top:15px !important}.margin-top-16{margin-top:16px !important}.margin-top-18{margin-top:18px !important}.margin-top-20{margin-top:20px !important}.margin-top-25{margin-top:25px !important}.margin-top-30{margin-top:30px !important}.margin-top-35{margin-top:35px !important}.margin-top-40{margin-top:40px !important}.margin-top-45{margin-top:45px !important}.margin-top-50{margin-top:50px !important}.margin-bottom-0{margin-bottom:0px !important}.margin-bottom-5{margin-bottom:5px !important}.margin-bottom-8{margin-bottom:8px !important}.margin-bottom-10{margin-bottom:10px !important}.margin-bottom-12{margin-bottom:12px !important}.margin-bottom-15{margin-bottom:15px !important}.margin-bottom-16{margin-bottom:16px !important}.margin-bottom-18{margin-bottom:18px !important}.margin-bottom-20{margin-bottom:20px !important}.margin-bottom-25{margin-bottom:25px !important}.margin-bottom-30{margin-bottom:30px !important}.margin-bottom-35{margin-bottom:35px !important}.margin-bottom-40{margin-bottom:40px !important}.margin-bottom-45{margin-bottom:45px !important}.margin-bottom-50{margin-bottom:50px !important}.margin-left-0{margin-left:0px !important}.margin-left-5{margin-left:5px !important}.margin-left-8{margin-left:8px !important}.margin-left-10{margin-left:10px !important}.margin-left-12{margin-left:12px !important}.margin-left-15{margin-left:15px !important}.margin-left-16{margin-left:16px !important}.margin-left-18{margin-left:18px !important}.margin-left-20{margin-left:20px !important}.margin-left-25{margin-left:25px !important}.margin-left-30{margin-left:30px !important}.margin-left-35{margin-left:35px !important}.margin-left-40{margin-left:40px !important}.margin-left-45{margin-left:45px !important}.margin-left-50{margin-left:50px !important}.margin-right-0{margin-right:0px !important}.margin-right-5{margin-right:5px !important}.margin-right-8{margin-right:8px !important}.margin-right-10{margin-right:10px !important}.margin-right-12{margin-right:12px !important}.margin-right-15{margin-right:15px !important}.margin-right-16{margin-right:16px !important}.margin-right-18{margin-right:18px !important}.margin-right-20{margin-right:20px !important}.margin-right-25{margin-right:25px !important}.margin-right-30{margin-right:30px !important}.margin-right-35{margin-right:35px !important}.margin-right-40{margin-right:40px !important}.margin-right-45{margin-right:45px !important}.margin-right-50{margin-right:50px !important}.margin-tb-0{margin-top:0px !important;margin-bottom:0px !important}.margin-tb-5{margin-top:5px !important;margin-bottom:5px !important}.margin-tb-8{margin-top:8px !important;margin-bottom:8px !important}.margin-tb-10{margin-top:10px !important;margin-bottom:10px !important}.margin-tb-12{margin-top:12px !important;margin-bottom:12px !important}.margin-tb-15{margin-top:15px !important;margin-bottom:15px !important}.margin-tb-16{margin-top:16px !important;margin-bottom:16px !important}.margin-tb-18{margin-top:18px !important;margin-bottom:18px !important}.margin-tb-20{margin-top:20px !important;margin-bottom:20px !important}.margin-tb-25{margin-top:25px !important;margin-bottom:25px !important}.margin-tb-30{margin-top:30px !important;margin-bottom:30px !important}.margin-tb-35{margin-top:35px !important;margin-bottom:35px !important}.margin-tb-40{margin-top:40px !important;margin-bottom:40px !important}.margin-tb-45{margin-top:45px !important;margin-bottom:45px !important}.margin-tb-50{margin-top:50px !important;margin-bottom:50px !important}.margin-lr-0{margin-left:0px !important;margin-right:0px !important}.margin-lr-5{margin-left:5px !important;margin-right:5px !important}.margin-lr-8{margin-left:8px !important;margin-right:8px !important}.margin-lr-10{margin-left:10px !important;margin-right:10px !important}.margin-lr-12{margin-left:12px !important;margin-right:12px !important}.margin-lr-15{margin-left:15px !important;margin-right:15px !important}.margin-lr-16{margin-left:16px !important;margin-right:16px !important}.margin-lr-18{margin-left:18px !important;margin-right:18px !important}.margin-lr-20{margin-left:20px !important;margin-right:20px !important}.margin-lr-25{margin-left:25px !important;margin-right:25px !important}.margin-lr-30{margin-left:30px !important;margin-right:30px !important}.margin-lr-35{margin-left:35px !important;margin-right:35px !important}.margin-lr-40{margin-left:40px !important;margin-right:40px !important}.margin-lr-45{margin-left:45px !important;margin-right:45px !important}.margin-lr-50{margin-left:50px !important;margin-right:50px !important}
  

Some of these styles are used but many are probably never used in my website's codebase.

Searching for each class manually would be a huge effort that would require repeating each time I update any page. Alternatively, tree-shaking tools will do this automatically.

Purgecss was the tree-shaking library that I selected for my site. It is a javascript library that takes two inputs, stylesheets and HTML files, and outputs each stylesheet with only the styles detected in the provided HTML files.

Rollup to the Rescue

To ensure tree shaking occurs automatically each time we deploy to production, we need to add purgecss as a build step. One way to do this is to write a rollup plugin that will fire after the production stylesheets are bundled.


    async function treeShakeCSS() {
      // get raw html
      const filesHTML = await filewalker({
        rootDir: join(Deno.cwd(), 'public'),
        pattern: new RegExp(/index\.html/),
      });
      const rawHTML = await raw(filesHTML);
      // get raw js
      const filesJS = await filewalker({
        rootDir: join(Deno.cwd(), 'public/assets/js'),
        pattern: new RegExp(/\.js/),
      });
      const rawJS = await raw(filesJS);
      // array of content that could have classes defined within it
      const content = [
        ...Object.values(rawHTML).map((raw) => {
          return {
            extension: 'html',
            raw: raw,
          };
        }),
        ...Object.values(rawJS).map((raw) => {
          return {
            extension: 'js',
            raw: raw,
          };
        }),
      ];
      // get raw css
      const filesCSS = await filewalker({
        rootDir: join(Deno.cwd(), 'public/assets/css'),
        pattern: new RegExp(/\.css/),
      });
      const rawCSS = await raw(filesCSS);
      const css = Object.keys(rawCSS).map((name) => {
        return {
          name,
          raw: rawCSS[name],
        };
      });
      // purge
      const result = await new PurgeCSS().purge({
        css,
        content,
      });
      // write
      await result.forEach(async (r) => {
        if (r.file) {
          await Deno.writeTextFile(r.file, r.css);
        }
      });
    }

    const treeShakeCSSOutput = {
      // using the rollup output lifecycle hook "writeBundle",
      // we can look at the outputted css in 'public/assets/css',
      // then run the PurgeCSS function to treeshake those bundles,
      // finally we save the purged output to the same bundle location
      name: 'treeShakeCSSOutpue',
      writeBundle() {
        return treeShakeCSS();
      },
    };
  

The plugin above runs when rollups writeBundle lifecycle hook is triggered. At this point the site's stylesheets are bundled and written to the filesystem but they still contain unused styles. The plugin does the following:

  1. First, we gather the outputted bundled stylesheets, javascript, and HTML files. Javascript files are included because sometimes classes are added dynamically by functions or bundled into web components.
  2. Next, we gather the raw content of each of those files using the runtime filesystem functions. My site uses Deno but where relevant, filesystem functions can be substituted with those provided by node or other javascript runtimes.
  3. Then we give purgecss an array of the raw stylesheets and content that could potentially contain styles defined in those stylesheets. The output of this function is an object that contains a filename and the treeshaken stylesheet.
  4. Finally we overwrite each of the bundled stylesheets with the treeshaken content returned by purgecss.

Then attach the plugin to the output options in your rollup config:


    // RollupOptions { //... 
    output: {
      // output stuff here
      plugins: [treeShakeCSSOutput],
    },
    plugins: [
      // DO NOT attach the plugin here because the plugin needs to run following output
    ]
  

Results

Below is a screenshot of my network calls after attaching and pushing my new rollup config to production. Over 50% decrease in my total bundled stylesheet size. But perhaps the best result is that lighthouse will no longer provide a warning about unused styles.

Before After 🍃 Difference
Stylesheet (Bytes) (Bytes) (Bytes)
base.scss 20,780 9,615 11,165 (53.7%)
blog.scss 712 638 74 (10.3%)
index.scss 227 227 0
TOTAL: 21,719 10,480 11,239 (51.7%)

Wrapping Up

Tree shaking is an effective way to optimize CSS bundles and improve the performance of websites. There are ways to get this optimisation out-of-the-box, but in this article I have shared one way to make use of tree shaking in the context of my personal website, including benchmarking and integration into my build system. By implementing tree shaking, bundled styles decreased by over 50%.

One potential downfall of this solution is that it does not provide a way to integrate content that is generated dynamically on the server-side. One way to possibly include dynamically-generated content in a tree-shaking system is to create an endpoint that will output the templates and content generated for a production environment. Once that content is captured (e.g., perhaps in a pre-build step), it can be included in the purge plugin.