Performance-Fresser ■ Episode 07
CSS-in-JS is the practice of writing CSS inside JavaScript in order to generate CSS. One writes a style declaration, hands it to a runtime library, which parses it, hashes it, injects it into the DOM, and produces CSS. The browser then reads the CSS. The same CSS one could have written in the first place.
The engineering term for this is a translation layer. The colloquial term is a detour. The accurate term, if one is feeling uncharitable, is sending bananas to New Zealand so they can be reimported with a more fashionable label.
The Runtime Overhead
styled-components, the most popular CSS-in-JS
library, operates at runtime. On every render, it parses template
literals, computes styles from props, generates unique class names via
hashing, and injects <style> tags into the document head.
Every render. Not once. Not on first mount. Every single time the component
renders.
This is not an implementation detail one can optimise away. It is the architecture. The library must evaluate JavaScript to produce CSS because the styles do not exist until the JavaScript executes. They are not in a stylesheet. They are not in the document. They are in a template literal, waiting for a runtime to translate them into something the browser already knows how to read.
A static CSS file, by contrast, is parsed once, cached by the browser, and applied without executing a single line of JavaScript. The runtime cost is zero. Not low. Zero. The browser was built to do this. It has been doing it since 1996. It is, one might venture, rather practised.
The Bundle Tax
Before a single styled component can render, the browser must download, parse, and execute the library itself. The admission fee is not trivial.
styled-components adds roughly 13 KB minified and gzipped.
Emotion adds roughly 11 KB. These are the transfer sizes.
The browser must decompress, parse, and execute considerably more. And for what?
To produce output that a .css file delivers for nothing.
Thirteen kilobytes of JavaScript whose sole purpose is to write CSS. Not to add interactivity. Not to fetch data. Not to manage state. To write CSS. One struggles to imagine a less efficient allocation of bytes.
Hydration Roulette
Server-side rendering with CSS-in-JS introduces an additional dimension of entertainment. The server renders the component, generates the styles, and injects them into the HTML. The browser displays the page. It looks correct. Then the JavaScript arrives.
The client-side runtime now regenerates the styles from scratch. It hashes the template literals again. If the hashes match (and they usually do, but “usually” is doing rather a lot of heavy lifting in a production environment), the styles survive. If they do not match, the user sees a flash of unstyled content, or worse, the wrong styles applied until the JavaScript finishes executing.
The browser already had the correct styles. The server sent them. They were working. Then the runtime arrived and insisted on verifying the answer by solving the equation again, and occasionally got a different result. One does not normally applaud a system that introduces errors into previously correct output, but the industry has been remarkably tolerant.
DevTools Archaeology
Open the browser’s developer tools on a styled-components project.
Inspect an element. The class name reads .sc-bdVaJa. Or
.sc-htpNat. Or .eFGRsN. The name is a hash,
generated at runtime, different on every build, and it tells you precisely
nothing about what the element is, where the style is defined, or why it
looks the way it does.
In a standard CSS architecture, a class name is a contract.
.button--primary tells you what the element is and which
variation you are looking at. In CSS-in-JS, the class name is a hash. The
contract has been replaced with a fingerprint, and fingerprints are useful
for identification but rather less useful for comprehension.
Debugging becomes archaeology. You cannot search the codebase for
.sc-bdVaJa because the string does not appear in the source.
It was generated. You must trace the element back through the React component
tree, find the styled component definition, read the template literal, and
mentally reconstruct which CSS rules apply. In a static stylesheet, you would
press Ctrl+F. The distance between these two workflows is the distance between
engineering and excavation.
The “Dynamic Styles” Defence
The standard justification for the runtime is dynamic styling. Components need to change their appearance based on props. The framework must evaluate JavaScript to determine which styles apply. It sounds reasonable until one examines what “dynamic” actually means in practice.
Here is the canonical example, in styled-components:
const Button = styled.button`
color: ${props => props.primary ? 'blue' : 'grey'};
`;
A runtime library evaluates a JavaScript function on every render to select between two colours. Here is the CSS equivalent:
.button { color: grey; }
.button.primary { color: blue; }
One requires a 13 KB library, a runtime parser, a hashing algorithm, and DOM injection on every render cycle. The other requires the browser to do what it has done since Netscape Navigator. The output is identical. The cost is not.
The vast majority of “dynamic” styles in production applications are binary toggles. Active or inactive. Primary or secondary. Open or closed. These are not computations. They are conditions. CSS has had conditional application since the cascade was invented. A class on an element is a condition. A specificity rule is a condition. The runtime is solving a problem that was solved before JavaScript existed.
The Solutions That Already Exist
The scoping problem, the genuine concern that CSS-in-JS claims to address, was solved years before styled-components shipped its first release.
BEM (Block Element Modifier) arrived in 2010. A naming
convention. .block__element--modifier. No tooling required.
No build step. No runtime. The class name is the scope. It works because
developers agree on a naming pattern, which is precisely how every other
naming problem in software has been solved since the invention of the
subroutine.
CSS Modules arrived in 2015. Locally scoped class names, generated at build time. The output is a static stylesheet with unique identifiers. Zero runtime. The scoping is resolved before the code ships. The browser receives plain CSS, and plain CSS is what browsers do best.
And then there are CSS Custom Properties, native CSS variables, supported by 98% of browsers worldwide. Dynamic values, in CSS, without JavaScript. One sets a property on an element; the cascade does the rest. The “dynamic” argument for CSS-in-JS collapses the moment one acknowledges that CSS itself became dynamic in 2017.
:root { --btn-colour: grey; }
.button.primary { --btn-colour: blue; }
.button { color: var(--btn-colour); }
Dynamic. Cascading. Scoped. Zero runtime. Zero bytes of JavaScript. The browser does it natively, has done for years, and does it faster than any library can because the style engine is compiled C++, not interpreted JavaScript.
The Translation Paradox
The fundamental absurdity is structural. Consider the pipeline.
The developer writes CSS. In JavaScript. The JavaScript is shipped to the browser. The browser executes the JavaScript. The JavaScript generates CSS. The browser reads the CSS. The input is CSS. The output is CSS. The intermediary is JavaScript, a language the browser must download, parse, and execute in order to produce something it could have received directly.
One would not write English, translate it to Mandarin, fax it to a translation service, and have it translated back to English for delivery to an English-speaking audience. Yet this is precisely the architecture CSS-in-JS proposes for stylesheets. The browser speaks CSS. It has spoken CSS since 1996. Perhaps we should let it.
The Invoice
Let us itemise.
~13 KB of runtime library, downloaded and parsed on every
page load, to produce output the browser handles natively.
Template literal parsing on every render cycle, turning
strings into style rules at a cost measured in milliseconds that compound
across interactions. Hash generation for class names that
tell the developer nothing and change on every build.
DOM injection of <style> tags at runtime,
a pattern browsers were not optimised for and frameworks were not designed
to throttle. Hydration mismatches when server and client
generate different hashes, producing visual artefacts on pages that were
correct before JavaScript arrived.
Every one of these costs exists because of the translation layer. Remove the layer and every cost vanishes. The CSS file is parsed once, cached indefinitely, and applied without a single line of JavaScript. The class names are readable. The DevTools show what you expect. The server sends styles; the browser applies styles; there is no intermediate negotiation.
CSS-in-JS solved a workflow preference. It made styles co-located with components, which some teams prefer, and that preference is not unreasonable. But a workflow preference that costs 13 KB, a runtime parser, hydration fragility, and opaque debugging (when the alternative is zero bytes, no runtime, and a language the browser already speaks) is a preference the user is paying for. And the user did not choose it.
The fastest stylesheet is the one the browser does not have to generate.
The browser understands CSS. It has understood CSS for nearly thirty years. It will understand the CSS you write today, tomorrow, and a decade from now, without a runtime, without a library, without a single kilobyte of JavaScript standing between your styles and the screen. The tax is voluntary. One simply has to stop paying it.