CSS Pipeline @ Housing.com

Dhruvil Patel
Engineering @ Housing/Proptiger/Makaan
5 min readMar 16, 2023

Part 2: Optimising CSS. Media query filtering, mobile first styles, css-nano

This article is second in a series of articles related to CSS Pipeline at Housing.com. We recommend reading the previous article before proceeding further.

Problem 1: Dead code elimination

At Housing.com we made another opinionated choice. We don’t have a responsive website. Our design wont support it, if we did try to retrofit we would lose out on performance a lot. So we took a hybrid approach, which can be summarised as follows:

  1. Top level page containers are different on desktop and mobile
  2. Then on basis of design & features we take a call on component level. If there are significant differences between desktop & mobile variants of a feature, we create completely separate components. If however, the differences are minor, we create a common component and then add conditional checks within the same component. [but this isn’t a performance problem for us because we do tree shaking & dead code elimination properly]. We take a similar approach when it comes to CSS. only difference is instead of using a conditional check like [if (isMobile)] we do within JS, we instead use media queries [@media (min-width: 1100px)] for creating differences in our desktop & mobile variants [Please note that: responsive design is not our goal, we are just using media queries to create different styles]

The point 2 above created an opportunity for dead code elimination which we explored. We used a postcss plugin named postcss-media-query-filter to filter out the desktop specific media queries from our mobile builds

Problem 2: Enforcing mobile first styles

Our solution to problem one would be really only effective if our developers write mobile first styles. To enforce it, we created another postcss plugin to only allow a set of media queries. And these whitelisted media queries would enforce mobile first styles. Example allowing[@media (min-width: 1100px)] instead of [@media (max-width: 1099px)]

Question may arise why not an Eslint plugin instead? Well, because we don’t write plain css. We are using linaria and media queries in our source code might be interpolated. Example:

import minDesktopWidth from './media-queries'
import {css} from '@linaria/core'

export const myStyle = css`
font-size: 12px;
color: red;

// these kind of interpolations would be difficult to catch with eslint

@media (${minDesktopWidth}) {

font-size: 14px;
color: blue;
background: hotpink;

}`

The postcss-plugin runs on the final css produced by linaria and is very simple:

const availableMediaRules = [
'min-width: 1100px',
'min-width: 1900px'
]

module.exports = () => {
return {
postcssPlugin: 'postcss-media-rules-check',
AtRule: {
media: atRule => {
let validAtRule = availableMediaRules.some(mediaRule =>
atRule.params
.replace(/\s+/g, '')
.includes(mediaRule.replace(/\s+/g, ''))
)
if (!validAtRule) {
throw new Error(
`${atRule.params} media query is not allowed. Use media queries from available media rules.
We should write mobile first styles and use media queries for desktop specific.`
)
}
}
}
}
}
module.exports.postcss = true

Problem 3: Limitations with MiniCssExtractPlugin

As mentioned in our previous post, linaria is not the complete CSS pipeline, it leaves a lot of things to the developers. Therefore, we adopted MiniCssExtractPlugin with css-loader. We added a few optimisations like css-nano, autoprefixer etc. But we soon found css-nano wasn’t working properly [for one thing it wasn’t deduplicating styles as we would expect it to do]. As an example:


// input
.a {
color: red;
}

.a {
font-size: 12px;
}
.a {
color: red;
}



// expected output after passing with css-nano
.a{
color:red;
font-size:blue;
}




// actual output we saw after passing with css-nano
.a{
color:red;
font-size: 12px;
}
.a {
color: red;
}

We scratched our heads and found out that css-nano wasn’t at fault. It was inefficient CSS Pipeline which caused this. The problem could be summarised as follows:

  1. We had the following chain: PostCSS [with css-nano as a plugin] -> css-loader -> MiniCssExtractPlugin.loader -> MiniCssExtractPlugin
  2. What this meant was PostCSS (and hence css-nano) was running on each individual file, and not on the complete css-chunk created by MiniCssExtractPlugin. To understand that better:
// input - style1.css
.a {color: red;}
.a {font-size: 12px;}


// output - style1.css after postcss and cssnano
.a{color: red; font-size: 12px;}



// input - style2.css
.a {color: red;}


// output - style2.css after postcss and cssnano
.a{color: red;}

And then these output files were just concatenated by MiniCssExtractPlugin into a final chunk:

// combined css chunk style1-style2.css
.a{color: red; font-size: 12px;}
.a{color: red;}

This was why it looked as if cssnano wasn’t working properly.

Solution? We somehow needed to run postcss on the final CSS chunk. Therefore, we wrote a Webpack plugin for the same:

const POSTCSS = require('postcss')

class ProcessCSSPostBundle {
constructor() {
}
process(source, callback) {
return POSTCSS([
require('cssnano')({
preset: 'default'
})
])
.process(source, {
from: '',
to: ''
})
.then(result => {
callback(result.css)
})
}
apply(compiler) {
const {RawSource, CachedSource} = compiler.webpack.sources
compiler.hooks.compilation.tap('ProcessCSSPostBundle', compilation => {
compilation.hooks.processAssets.tap(
{
name: 'ProcessCSSPostBundle',
stage: 'PROCESS_ASSETS_STAGE_OPTIMIZE'
},
assets => {
Object.keys(assets).map(i => {
if (i.indexOf('.css') >= 0) {
// <-- flag for your source file here
this.process(assets[i].source(), results => {
assets[i] = new CachedSource(new RawSource(results))
})
}
})
}
)
})
}
}
module.exports = ProcessCSSPostBundle

This did the trick for us!

This though brings us to another problem with MiniCssExtractPlugin. MiniCssExtractPlugin works on chunk level, therefore, if we don’t do code-splitting properly, the output css chunk sizes increase significantly, that means a lot more unused css. This is especially a concern if we are inlining that css from the server into the HEAD tag. Since, css is render blocking, a lot of extra CSS can impact performance severely. The solution we thought for this is somewhat similar to what the good old isomorphic-style-loader did in the days of css-modules. We’ll discuss about this in another article later in this series.

That’s all folks! Please do share your valuable feedback & keep following this space for more such content.

This is a second in the series of articles on our CSS Pipeline.

Check out the other articles of this series: Part 1, Part 3, Part 4, Part 5

Once again shout out to the ones who helped achieve this: sachin agrawal, Naman Saini, Nikhil Verma, Sachin Tyagi

--

--