Tailwind vs BEM - Part 1 (Performance Comparison)
Published: 2023-11-15 05:59:26

[The second article here](https://dev.to/nikolaymakhonin/tailwind-vs-bem-part-2-architecture-325i)

In these two articles, I will be comparing TailwindCSS with pure CSS + BEM. The goal is to understand which is the better solution for good application architecture. This is not a matter of preference, as this choice will significantly impact the later stages of development and must be very well justified. I'll start with comparing performance. Tailwind allows for a substantial reduction in the final CSS size, thereby speeding up page display time. However, this only works if Tailwind classes are written directly in HTML code, not as `@apply` in CSS. Tailwind reduces CSS size but increases HTML size. Let's calculate the difference, taking into account HTML. We'll compare pure Tailwind with pure CSS + BEM.

## Calculations
For each Tailwind class - one property. For each HTML element - one and a half BEM classes (if considering modifiers).

```javascript
lt - average length of a Tailwind class
lc - average length of a BEM class
lp - average length of a CSS property
Pu - number of unique properties
P - average number of properties per element
C - average number of BEM classes per element
E - number of elements
Eu - number of unique elements

CSS structure for Tailwind:
.lt * Pu {
  lp * Pu;
}

CSS structure for BEM:
.lc * Eu * C { 
  lp * Eu * P;
}

HTML structure for Tailwind:
<div class="lt * E * P">

HTML structure for BEM:
<div class="lc * E * C">

Calculating total size:
Tailwind: lt * Pu + lp * Pu + lt * E * P
BEM: lc * Eu * C + lp * Eu * P + lc * E * C
```

Function for browser experiments:
```javascript
function calc({lt, lc, lp, Pu, P, C, E, Eu}) {
  const Tailwind = lt * Pu + lp * Pu + lt * E * P
  const BEM = lc * Eu * C + lp * Eu * P + lc * E * C
  return {
    Tailwind,
    BEM,
    diff: Tailwind / BEM
  }
}
```

For the analysis, I took the main page of GitHub after login. Here are some tools for analysis:
```javascript
// Counting elements on the site:
console.log(Array.from(document.querySelectorAll('[class]')).length)

// Counting unique (by class) elements on the site:
console.log(Array.from(document.querySelectorAll('[class]'))
  .reduce((a, o) => {
    a.add(Array.from(o.classList.values()).sort().join(' '))
    return a
  }, new Set()).size)
```

```
Page: https://github.com/
3164 elements
502 unique elements
12101 all CSS properties
3956 unique CSS properties
404088 total length of all CSS properties
163733 total length of unique CSS properties
33 average length of all CSS properties
41 average length of unique CSS properties
24 properties per element
```

```javascript
calc({
  lt:  10, // average length of a Tailwind class
  lc:  35, // average length of a BEM class
  lp:  35, // average length of a CSS property
  Pu:  3956, // number of unique properties
  P:  24, // average number of properties per element
  C:  1.5, // average number of BEM classes per element
  E:  3164, // number of elements
  Eu:  502, // number of unique elements
})

// Tailwind: 937380,
// BEM: 614145,
// Tailwind / BEM: 1.5263170749578682
```

**Tailwind size is 1.5 times larger**

## Optimization

For BEM, we could optimize CSS and for each property list all classes with that property in the selector. Then the total length of all properties should decrease:

```
.lc * Eu * C * P { 
  lp * Eu;
}
```

The formula for BEM will be as follows:
```
lc * Eu * C * P + lp * Eu + lc * E * C
```

Let's consider the same GitHub, but with class name obfuscation and optimization:

```javascript
function calc({lt, lc, lp, Pu, P, C, E, Eu}) {
  const Tailwind = lt * Pu + lp * Pu + lt * E * P
  const BEM = lc * Eu * C * P + lp * Eu + lc * E * C
  return {
    Tailwind,
    BEM,
    diff: Tailwind / BEM
  }
}

calc({
  lt:  5, // average length of a Tailwind class (with obfuscation)
  lc:  5, // average length of a BEM class (with obfuscation)
  lp:  35, // average length of a CSS property
  Pu:  3956, // number of unique properties
  P:  24, // average number of properties per element
  C:  1.5, // average number of BEM classes per element
  E:  3164, // number of elements
  Eu:  502, // number of unique elements
});

// Tailwind: 537920
// BEM: 131660
// Tailwind / BEM: 4.085675224061978
```

With optimization and obfuscation, Tailwind's size is 4 times larger than BEM's.

But, unfortunately, such optimization is not possible. This example illustrates why:
```html
<style>
.a {
  color: red;
}
.b {
  color: blue;
}
.c {
  color: red;
}
</style>

<div class="a b"></div> <!-- Blue -->
<div class="b c"></div> <!-- Red -->

=========================

<style>
.a, .c {
  color: red;
}
.b {
  color: blue;
}
</style>

<div class="a b"></div> <!-- Blue -->
<div class="b c"></div> <!-- Blue -->

=========================

<style>
.b {
  color: blue;
}
.a, .c {
  color: red;
}
</style>

<div class="a b"></div> <!-- Red -->
<div class="b c"></div> <!-- Red -->
```

But class name obfuscation is possible. Let's consider the case without impossible optimization, but with obfuscation.
```javascript
calc({
  lt:  5, // average length of a Tailwind class (with obfuscation)
  lc:  5, // average length of a BEM class (with obfuscation)
  lp:  35, // average length of a CSS property
  Pu:  3956, // number of unique properties
  P:  24, // average number of properties per element
  C:  1.5, // average number of BEM classes per element
  E:  3164, // number of elements
  Eu:  502, // number of unique elements
});

// Tailwind: 537920,
// BEM: 449175,
// Tailwind / BEM: 1.1975733288807258
```

Moreover, the size of a Tailwind class significantly impacts the total HTML/CSS size more than the size of a BEM class. This is understandable since the total number of elements on a page is 7 times more than the unique ones.

## Is the size of CSS/HTML important, and to what extent?

How big can CSS actually be? With BEM, it's not hard to get 3 megabytes. But gzip compresses 3MB to just 60kB. Of course, after downloading, the browser still needs to unpack the CSS and then analyze it, which takes some time. According to my tests, it's 0.5-1 second on desktop. On mobile devices, this time should be significantly longer. Fast loading and quick analysis of CSS/HTML are more important than images or even scripts. Scripts are given a small head start because the user does not interact with the page immediately. Without HTML/CSS, the page won't even display correctly, and image loading won't start.

## Script for analyzing a specific site

Run this script in the console for any site, and you will know the difference in sizes between Tailwind and BEM.
Preferably run it with browser protection disabled so that the script takes into account all styles loaded from CDNs:
```sh
chrome.exe --disable-web-security --user-data-dir="C:/Temp/ChromeDevSession"
```

The algorithm does not consider cascading styles, i.e., all that contain the symbols " >+~". It's very difficult to account for them, so it's better to just choose well-coded sites with little cascading. The algorithm also assumes that for BEM, each unique element has its unique style sheet. Therefore, in reality, the CSS size for BEM will be less than the algorithm estimates.
The calculations do not consider the current method of class naming. Only the quality of design and its coding affects the result. The Utility First approach forces more quality design and coding, i.e., adhering to certain predefined style sets. Therefore, probably sites coded with Tailwind show better results in terms of HTML/CSS size.
The script takes about a minute to run due to the `element.matches(rule.selectorText)` function. I don't know how to optimize this.


```javascript
function walkRules(rules, callback, mediaRule) {
  Array.from(rules).forEach(rule => {
    if (rule instanceof CSSStyleRule) {
      callback(rule, mediaRule)
    } else if (rule instanceof CSSMediaRule) {
      walkRules(rule.cssRules, callback, rule)
    }
  })
}

function getRules() {
  const rules = []
  Array.from(document.styleSheets).forEach(sheet => {
    // ignore cross-origin stylesheets
    try {
      sheet.cssRules
    } catch (err) {
      console.warn(`Stylesheet ${sheet.href} is not accessible`)
      return
    }

    walkRules(sheet.cssRules, (rule, mediaRule) => {
      if (
        !/\.[\w-]/.test(rule.selectorText) || // only class selectors
        /[ >+~]/.test(rule.selectorText) // exclude cascading selectors
      ) {
        return
      }
      const style = {}
      for (let i = 0; i < rule.style.length; i++) {
        const key = rule.style[i]
        style[key] = rule.style.getPropertyValue(key).trim().toLowerCase()
      }
      rules.push({ rule, mediaRule, style })
    })
  })

  return rules
}

function getElements(rules) {
  const allElements = Array.from(document.querySelectorAll('[class]'))
  const filteredElements = []

  const time0 = Date.now()
  console.log('getElements START')

  let logTime = Date.now()
  const nonVisualTags = ['SCRIPT', 'STYLE', 'LINK', 'META', 'TITLE', 'NOSCRIPT']
  allElements.forEach((element, i) => {
    if (nonVisualTags.includes(element.tagName)) {
      return
    }
    const elementRules = rules.filter(({ rule }) => {
      return element.matches(rule.selectorText)
    })
    if (elementRules.length === 0) {
      return
    }
    filteredElements.push({ element, rules: elementRules })
    if (Date.now() - logTime > 1000) {
      console.log(
        `getElements: ${((i / allElements.length) * 100).toFixed(2)}% ${
          (Date.now() - time0) / 1000
        }s`,
      )
      logTime = Date.now()
    }
  })

  console.log('getElements END', performance.now() - time0)

  return filteredElements
}

function calcStat() {
  const rules = getRules()
  const elements = getElements(rules)

  const uniqueElements = Array.from(
    elements
      .reduce((a, e) => {
        const key = e.rules.map(({ rule }) => rule.selectorText).join(' ')
        if (!a.has(key)) {
          a.set(key, e)
        }
        return a
      }, new Map())
      .values(),
  )

  const stat = {
    total: {
      html: {
        elementsCount: 0,
      },
      css: {
        selectorCount: 0,
        mediaCount: 0,
        mediaSize: 0,
        styleSize: 0,
        propCount: 0,
      },
    },
    tailwind: {
      html: {
        classCount: 0,
      },
      css: {
        selectorCount: 0,
        styleSize: 0,
        propCount: 0,
      },
    },
    bem: {
      html: {
        elementsCount: 0,
      },
      css: {
        selectorCount: 0,
        mediaCount: 0,
        mediaSize: 0,
        styleSize: 0,
        propCount: 0,
      },
    },
  }

  const uniqueProps = uniqueElements.reduce((a, e) => {
    e.rules.forEach(({ style }) => {
      Object.keys(style).forEach(key => {
        a.add(key + ':' + style[key] + ';')
      })
    })
    return a
  }, new Set())

  // Total
  stat.total.html.elementsCount = elements.length
  rules.forEach(({ rule, mediaRule, style }) => {
    Object.keys(style).forEach(key => {
      stat.total.css.propCount++
      const prop = key + ':' + style[key] + ';'
      stat.total.css.styleSize += prop.length
    })
    stat.total.css.selectorCount++
    if (mediaRule) {
      stat.total.css.mediaCount++
      stat.total.css.mediaSize += mediaRule.media.mediaText.trim().length
    }
  })

  // Tailwind
  stat.tailwind.css.selectorCount = uniqueProps.size
  stat.tailwind.css.propCount = uniqueProps.size
  uniqueProps.forEach(prop => {
    stat.tailwind.css.styleSize += prop.length
  })
  elements.forEach(({ element, rules }) => {
    const elemUniqueProps = new Set()
    rules.forEach(({ rule, mediaRule, style }) => {
      Object.keys(style).forEach(key => {
        elemUniqueProps.add(
          key +
            ':' +
            style[key] +
            ';' +
            (mediaRule?.media?.mediaText?.trim() || ''),
        )
      })
    })
    if (!elemUniqueProps.size) {
      debugger
    }
    stat.tailwind.html.classCount += elemUniqueProps.size
  })

  // BEM
  stat.bem.html.elementsCount = elements.length
  stat.bem.css.selectorCount = uniqueElements.length
  uniqueElements.forEach(({ element, rules }) => {
    const uniqueMedia = new Set()
    rules.forEach(({ rule, mediaRule, style }) => {
      const mediaText = mediaRule?.media?.mediaText?.trim() || ''
      if (mediaText) {
        uniqueMedia.add(mediaText)
      }
      Object.keys(style).forEach(key => {
        const prop = key + ':' + style[key] + ';'
        stat.bem.css.styleSize += prop.length
        stat.bem.css.propCount++
      })
    })
    stat.bem.css.mediaCount += uniqueMedia.size
    uniqueMedia.forEach(media => {
      stat.bem.css.mediaSize += media.length
    })
  })

  return stat
}

var stat = calcStat()
console.log(JSON.stringify(stat, null, 2))

function tailwindVsBem({
  stat,
  bemClassSize,
  bemClassesPerElement,
  tailwindClassSize,
}) {
  const bemCssSize =
    stat.bem.css.styleSize +
    stat.bem.css.mediaSize +
    stat.bem.css.selectorCount * bemClassSize +
    stat.bem.css.mediaCount * bemClassSize
  const bemHtmlSize =
    stat.bem.html.elementsCount * bemClassSize * bemClassesPerElement

  const tailwindCssSize =
    stat.tailwind.css.styleSize +
    stat.tailwind.css.selectorCount * tailwindClassSize
  const tailwindHtmlSize = stat.tailwind.html.classCount * tailwindClassSize

  const bemSize = bemCssSize + bemHtmlSize
  const tailwindSize = tailwindCssSize + tailwindHtmlSize
  const diff = tailwindSize / bemSize

  console.log(`
${document.location.href}
Tailwind = ${tailwindCssSize} (CSS) + ${tailwindHtmlSize} (HTML) = ${tailwindSize}
BEM = ${bemCssSize} (CSS) + ${bemHtmlSize} (HTML) = ${bemSize}
Tailwind / BEM = ${diff}
`)
}

tailwindVsBem({
  stat,
  bemClassSize: 35,
  bemClassesPerElement: 1.5,
  tailwindClassSize: 10,
})
```

### Results

Pages with pure CSS:
```
https://www.youtube.com
Tailwind = 38936 (CSS) + 347670 (HTML) = 386606
BEM = 129424 (CSS) + 202230 (HTML) = 331654
Tailwind / BEM = 1.1656907499984923

https://dzen.ru/
Tailwind = 19449 (CSS) + 165240 (HTML) = 184689
BEM = 80186 (CSS) + 75232.5 (HTML) = 155418.5
Tailwind / BEM = 1.1883334352088073


https://habr.com/ru/articles/774524/
Tailwind = 1173 (CSS) + 370 (HTML) = 1543
BEM = 946 (CSS) + 157.5 (HTML) = 1103.5
Tailwind / BEM = 1.3982782057091074
```

Pages with Tailwind or another Utility First approach:
```
https://github.com
(Микс чистого CSS и Tailwind)
Tailwind = 25103 (CSS) + 482850 (HTML) = 507953
BEM = 155295 (CSS) + 155190 (HTML) = 310485
Tailwind / BEM = 1.6359985184469459

https://stackoverflow.com/questions/588004/is-floating-point-math-broken/588014
Tailwind = 25317 (CSS) + 449170 (HTML) = 474487
BEM = 132373 (CSS) + 140437.5 (HTML) = 272810.5
Tailwind / BEM = 1.7392549040451155

https://www.facebook.com/random_user
Tailwind = 42286 (CSS) + 400300 (HTML) = 442586
BEM = 378248 (CSS) + 170415 (HTML) = 548663
Tailwind / BEM = 0.8066627419745819

https://tailwindcss.com
Tailwind = 22325 (CSS) + 159330 (HTML) = 181655
BEM = 120739 (CSS) + 121800 (HTML) = 242539
Tailwind / BEM = 0.7489723302231805

https://www.shopify.com
Tailwind = 25053 (CSS) + 102080 (HTML) = 127133
BEM = 179634 (CSS) + 78750 (HTML) = 258384
Tailwind / BEM = 0.4920312403244783

https://www.netflix.com/tudum/top10/
Tailwind = 13288 (CSS) + 80360 (HTML) = 93648
BEM = 48737 (CSS) + 60375 (HTML) = 109112
Tailwind / BEM = 0.8582740670137107

https://io.google/2022/
Tailwind = 11124 (CSS) + 27390 (HTML) = 38514
BEM = 43079 (CSS) + 21682.5 (HTML) = 64761.5
Tailwind / BEM = 0.5947051874956572

https://dotnet.microsoft.com/en-us/
Tailwind = 15199 (CSS) + 27520 (HTML) = 42719
BEM = 49361 (CSS) + 13860 (HTML) = 63221
Tailwind / BEM = 0.6757090207367805
```

## Conclusions

Tailwind reduces HTML/CSS size only if the entire web page (both design and coding) is designed using the Utility First approach. Of course, this must be agreed upon by everyone: designer, coder, and client. With very good design and coding, you can achieve a size twice smaller than with BEM coding.
If there is an arbitrary layout and you need to code in Pixel Perfect, then Tailwind is more likely to increase the size by 1.5 times. Also, Tailwind won't help if the page has a lot of repetitive elements (like the StackOverflow site, for example).

## P.S.

Tailwind [lies](https://tailwindcss.com/docs/optimizing-for-production) about phenomenal performance improvements. (In this article, it only considers the size of the files for download). It writes that Netflix has only 10KB of CSS, but doesn't mention the increased size of HTML. The total length of all "class" attributes equals 87231. This is a bit more than what I calculated here. In fact, Tailwind only saved 15% of the total size of uncompressed HTML+CSS.

Here's a script for calculating the total size of classes in HTML:
```javascript
Array.from(document.querySelectorAll('[class]'))
  .reduce((a, o) => {
    a += o.getAttribute('class').length
    return a
  }, 0)
```

----

I would be happy to hear any criticism, remarks, your personal experience, ...