Frontend performance optimization for Drupal websites: Part 3
This is part 3 of a series of articles that defines our approach to frontend performance optimization. In this part we challenge the prevailing wisdom of the monolithic CSS file.
In part 1 we covered cached optimization so that we’re only asking the server to produce a response when absolutely necessary. In part 2 we discussed image optimization so that the assets we do ship to the browser tread as lightly as possible. If you haven't read those posts yet, we suggest you start from the beginning.
Capellic maintains a suite of nonprofit Drupal websites that have been built over the past several years, using a very familiar but steadily evolving toolset. We adopted a component-driven approach to design and development years ago, and have steadily kept up with the popular tools used in the Drupal community to produce themes that adhere to the pattern.
The next step on our optimization journey is really what prompted this series to begin with. After we've made sure the site is churning out statically cached pages as often as possible, and we've ensured that all the fancy parts of those pages are as small and nimble as possible, we need to ensure that as much of what we're sending over the wire as possible as actually useful to the visitor as well.
Ship only what the visitor needs at the moment
One of the fastest things you can do is nothing. Nothing happens instantly. Nothing happens before you even think about it. When it comes to software no code is faster than literally no code.
The famed automotive legend and Lotus founder Colin Chapman is often quoted as saying, “Simplify, and then add lightness.”
Chapman understood that simply carrying around less mass could make a car faster just as effectively as a bigger engine, and it provided a myriad of other benefits too, ranging from fuel efficiency to handling and durability. You could achieve better results without adding extra complexity and concerns by simply removing what wasn’t needed to accomplish the job.
For websites, one of the biggest areas where we’re routinely carrying excess baggage is in front-end assets, like CSS and Javascript.
We should send website visitors the minimum amount of data they need to consume the content on the screen. When visiting the homepage, users don’t need all of the CSS and Javascript used on an event page. When visiting a product page, they don’t need all of the code to make the business locations map work. Ship visitors only the assets used to render the content they are currently viewing and you’ll make the experience more pleasant for them and better for the environment too.
At Capellic we use Drupal to build client websites, and it is a fantastic tool to build performant web experiences. Drupal’s library system is an often underutilized tool for website performance. Using it, website CSS and Javascript can be served to users only when the content that needs them is served. Standard practice for theme building over the last decade steers Drupal developers away from fully leveraging this system.
Preprocessors
Sass has seen huge adoption across the Drupal world. Since 2013, I can’t remember a single codebase I spent any significant time working in that didn’t use some variant of a pre/post-processor workflow to produce stylesheets.
The most common way to set these tools up aggregates your CSS and your Javascript into single monolithic CSS and Javascript files.
There is a sound logic to this configuration. It is the simplest configuration, and it gets the job done. Variables just work across all of your SCSS (Sass) files without explicit imports in the files themselves, since the main manifest file includes them early. Your styles will all be appropriately prefixed, and you’ll only have to include a single library in your theme’s configuration. Simplicity itself.
For a working developer who has to know all the technologies on a website, from PHP to CSS and Javascript, there isn’t typically a lot of time to finetune the configuration of the front-end tooling. Things get configured close to the defaults, close to the setup that produces the minimum viable build that everyone is looking for. The website looks right, and everyone who gets to touch the project can just write some styles, reload the page, and see what they did.
Until recently, most Drupal base themes (at least that I’ve used), even those that embrace component-driven development and design shipped out of the box configured to produce a monolithic CSS and Javascript file. This has changed over the past couple of years. Emulsify (which we use heavily at Capellic) began splitting its component CSS in version 4.2.0 which was released in 2022. Radix, a Bootstrap-based starter theme, made a similar change in 2022. Other themes, like the venerable Bootstrap theme, have begun to ship split Javascript files, but still ship a monolithic CSS file.
The Drupal community, as far as I can tell, more or less began embracing component-driven development a decade ago when John Albin was presenting on KSS(Knyle Style Sheets) and style guide-driven development. Since then we’ve had Pattern Lab, and now Storybook as the fan-favorite systems for building out design systems for Drupal websites.
These tools helped us to learn to think of our designs and themes in a modular way at the design phase, but we kept on compiling those neatly curated theme components into one large CSS file, shipped to every user on every page for years.
For a time, there was a technical reason to ship single files when possible. In the era of HTTP/1, there was an argument to reduce the number of assets because it was not possible to multiplex the connection and download the assets in parallel. Each connection took time to establish, and it blocked all the connections that followed it. Since the widespread adoption of HTTP/2 in modern browsers, this is no longer a concern. See this Cloudflare article for more information on the difference between HTTP/1 and HTTP/2.
At the time, the problem we were solving was the cost and frustration of theme development, not the cost and frustration of page speed and carbon usage. We were concerned with shipping high-quality, visually stunning designs, to websites with record turnaround times and minimal rework and thrashing. The adoption of these practices was nothing short of amazing for Drupal developers working to ship complex projects.
Modern Frontend Frameworks and Single Directory Components
Around the same time the Drupal community was getting wise to components, one of the most popular Javascript frameworks in modern history hit the scene, React. Not long after React, my favorite full-fat framework VueJS also graced the world with its presence. I can’t speak for the experience developers first had with React, but when I began to use VueJS, I was immediately hooked on the concept of single file components. A single .vue file shipped all of the markup, Javascript, and CSS (or SCSS) needed to make a component work. Along with an easy-to-use options API, it made building complex and reusable components a joy.
That paradigm began making its way into standard Drupal theming. Projects like Emulsify are set up to co-locate your CSS, template, and Javascript in a single folder, providing the same conceptual benefits.
The Drupal components module, followed by the new Single Directory Components core module have all but sealed the deal, that not only should we design in atomic components, but we should ship them to the end user the same way.
Single Directory Components has recently been promoted from experimental status to stable, coming to Drupal 10.3, but Drupal core supports manually splitting out CSS and Javascript libraries today, and has for quite some time:
- Compile your CSS into separate files (or just write plain CSS in separate files, to begin with).
- Add those files as entries to your theme/module libraries.yml file
- Attach those libraries (through various methods) to the page only when they are needed.
This three-step process will have some involved sub-steps along the way, depending on how your theme is configured.
Compiling to separate files
This is probably the most difficult part, depending on how well you know the API of your theme bundler (if you’re bundling at all). It could look something like this in Gulp:
Old:
// compile CSS
gulp.task('css-compile', function () {
return gulp.src(['scss/style.scss'])
.pipe(sass().on('error', sass.logError))
.pipe(concat('app.min.css'))
.pipe(postcss([autoprefixer()]))
.pipe(cleanCSS({compatibility: 'ie9'}))
.pipe(gulp.dest('css'))
.pipe(browserSync.stream());
});
New:
// compile CSS
function globalCss() {
return src('scss/global.scss')
.pipe(sassGlob())
.pipe(sass().on('error', sass.logError))
.pipe(postcss([autoprefixer()]))
.pipe(cleanCSS({compatibility: 'ie9'}))
.pipe(dest('css'))
.pipe(browserSync.stream());
}
function componentCss() {
return src('scss/components/**/*.scss')
.pipe(sassGlob())
.pipe(sass().on('error', sass.logError))
.pipe(postcss([autoprefixer()]))
.pipe(cleanCSS({compatibility: 'ie9'}))
.pipe(dest('css'))
.pipe(browserSync.stream());
}
This is an example from a refactor we recently undertook to improve the performance of a nonprofit that serves low income residents by providing plain language legal help information, interactive self-help tools and connections to local resources to help them resolve legal issues. It is critically important that those who don’t have equitable access to the legal system can reliably access this website even with a slow and unreliable connection to the internet.
This refactor also moved from a somewhat older to a newer style of gulp task writing, but you can mostly concern yourself with what is inside the functions rather than the style of their declaration. Here we’ve moved from a single CSS file to a split set of files, with a low-level global file that includes common styles used everywhere (font declaration, basic page element styles, and the grid framework).
An example using Emulsify/Webpack could look like this:
Old:
// css.js - this file is a single manifest with ALL of our sass partials imported.
import '../components/style.scss';
// webpack.common.js
function getEntries(pattern) {
const entries = {};
// We were on to something with the Javascript...
glob.sync(pattern).forEach((file) => {
const filePath = file.split('components/')[1];
const newfilePath = `js/${filePath.replace('.js', '')}`;
entries[newfilePath] = file;
});
// One monolithic but beautiful CSS file...
entries.css = path.resolve(webpackDir, 'css.js');
return entries;
}
Note: this setup is already splitting our JavaScript in the components directory, we were already on to something!
New:
// Glob pattern for scss files that ignore file names prefixed with underscore.
const scssPattern = path.resolve(rootDir, 'components/**/!(_*).scss');
// Glob pattern for JS files.
const jsPattern = path.resolve(
rootDir,
'components/**/!(*.stories|*.component|*.min|*.test).js',
);
// Prepare list of scss and js files for "entry".
function getEntries(scssPattern, jsPattern) {
const entries = {};
// SCSS entries - THIS IS THE GOODNESS!!!!
glob.sync(scssPattern).forEach((file) => {
const filePath = file.split('components/')[1];
const newfilePath = `css/${filePath.replace('.scss', '')}`;
entries[newfilePath] = file;
});
// JS entries - sameish as before
glob.sync(jsPattern).forEach((file) => {
const filePath = file.split('components/')[1];
const newfilePath = `js/${filePath.replace('.js', '')}`;
entries[newfilePath] = file;
});
// Global CSS file for things needed everywhere.
entries.style = path.resolve(webpackDir, 'css.js');
return entries;
}
The specifics and particulars are going to be up to how your theme is constructed, but the general idea is to loop over your component SCSS files and spit out a complimentary separate CSS file for each. You may have to also refactor the individual files to explicitly import the variables or helper functions used as well. Your bundler/compiler will typically tell you when this is an issue.
Setting up the libraries file
Now that you’ve got all these separate CSS files, it's time to jump into the theme_name.libraries.yml file to declare them all as libraries. The change is going to look a bit like this:
Old:
theme_name:
version: 1.0
css:
theme:
css/app.min.css: { minified: true}
...
New:
global-styling:
version: 1.x
css:
base:
css/global.css: { minified: true}
...
# components
accordion:
version: 1.x
css:
component:
css/accordion.css: { minified: true }
alert:
version: 1.x
css:
component:
css/alert.css: { minified: true }
alert_banner:
version: 1.x
css:
component:
css/alert_banner.css: { minified: true }
dependencies:
- theme_name/alert
...
I’ve left the Javascript out of this for brevity. What you can see is the addition of the component libraries.
Once the libraries are split out, the last step is to attach those libraries to the website when needed, which can be accomplished several ways, the simplest is to attach it to the twig template where the styles and scripts are needed. Here’s an example for the alert.twig component:
{{ attach_library('theme_name/alert') }}
<div{{attributes}}>
{{ content }}
</div>
This can also be accomplished via Drupal’s ‘libraries-override’ and ‘libraries-extend’ features, via a preprocess hook, or any other Drupal hook that allows for modification of the render array:
/**
* Implements hook_form_alter().
*/
function my_theme_form_alter(&$form, FormStateInterface $form_state, $form_id) {
$form['#attached']['library'][] = 'my_theme/forms';
}
With all that in place, you will only ship CSS and Javascript to visitors on the pages where they need them.
Frameworks and PurgeCSS
Once you’ve split the CSS and Javascript for your theme components, you’ll have made significant progress toward reducing your unused CSS footprint. There will typically however still be more global CSS. Styles such as basic element styles (h1s, p tags, etc), along with font definitions and perhaps most importantly, grid styles and utility classes.
Most of the basic element styles and font definitions will not be considered unused styles (if configured correctly), but where you likely will run into a significant portion of unused styles will be in any framework-oriented code, especially grids and utility classes.
These tools provide predictable options for you to apply to your website build such as containers, gutters, columns, and spacing utilities. They provide standardization, and predictability, and can significantly reduce the complexity of website CSS, but this comes at a cost.
Most websites will not exhaustively use every option of a grid or utility framework. If you don’t do anything about this, you are likely to ship hundreds of kilobytes of unused CSS with every page load. A good solution to this problem is the PurgeCSS tool.
Configuring PurgeCSS is fairly straightforward with some caveats:
purgecss({
content: [
'templates/**/*.twig',
'../../../modules/custom/**/templates/**/*.twig'
],
safelist: [/^icon-logged.*/, /.*block-postsociallinks.*/, /.*data-extlink.*/, 'figure']
})
The ‘content’ mapping provides a set of locations for PurgeCSS to find your website templates which should contain all of the CSS selectors your theme will use. The ‘safelist’ array is provided as an escape hatch for CSS you need to ensure is not purged which may not show up in your templates (at least not in a traditional way). This could be because your CSS targets something provided by another module, core, or anything else that isn’t provided in your theme templates. You could be dynamically building class names in your theme which PurgeCSS can’t parse, or you could be defining classes/markup in PHP functions.
The biggest issue you’ll run into is styles getting purged that you need. Playing with the content definition and the safelist can resolve most of these issues with minimal effort, but you can also avoid some complications by only running PurgeCSS against libraries that are likely to contain framework code. It is typically not necessary to purge styles written in component CSS files since those files will typically only contain CSS that laser targets the markup in the component.
With all this applied, you should now be shipping only the assets your visitors need, saving a load of time, CPU cycles, and carbon that all scale with the traffic of your website.
To review, we're now hopefully shipping statically cached pages whenever possible. The beautiful imagery we're delivering to visitors is light weight and tuned to deliver just the right pixels, and we're expertly delivering the CSS and Javascript that the visitor needs at that very moment and nothing more. If we stopped here, our website would likely already deliver great core web vital metrics. We'd be saving a ton of data transfer, and we'd be giving lots of valuable time back to the visitors of our site.
Next week will be the final post in this series, and we're going to cover the more complicated questions about what do we do when we don't have the option cut any more weight out of the page? What can we do when the the assets we have to deliver are still slowing the page down? We've still got a few tips and tricks up our sleeve to handle those situations that should help you deliver even faster pages for the citizens of the web, we hope to see you there.