With CSS-in-JS, we define the styles in JavaScript files. The browser, however, doesn't know how to deal with them in this form, so they need to reach the browser eventually in standard CSS syntax. There are two different, mutually exclusive methods to achieve this:
- Runtime stylesheets, used by the large majority of CSS-in-JS libraries;
- Static CSS extraction, used only by a handful of libraries.
This article will look at how these approaches work, debate their strong points and tradeoffs, and conclude when to consider one or the other.
Runtime stylesheets
Regardless of what tools we use to build our applications or what specific CSS-in-JS library that implements runtime stylesheets we use, the style definitions will be included in the .js
bundle, along with the components and the application logic.
<!-- styles get bundled along with the components & app logic -->
<script src="bundle.js"></script>
Since browsers don't know how to handle the styles included in JavaScript files, an additional runtime library is required. The runtime code is included by default in the bundle at build time, and its purpose is to:
- read the styles from the JavaScript bundle;
- inject the styles into the DOM;
- update the styles whenever an event triggers a change.
<!-- injects and updates styles to DOM -->
<!-- usually included in the bundle -->
<script src="library_runtime.js"></script>
Most CSS-in-JS libraries implement this method to output the CSS styles to the browser. As a side note, there are various methods to inject and update the styles:
- Appending one or more
<style>
tag(s) usually in the document's<head>
using the DOM API. This method is predominantly used in development as it provides better debuggability. - Managing the stylesheets directly on the CSSOM using the
CSSStyleSheet
API. This is the preferred method for production builds, as it appears to be more performant than manipulating<style>
tags within the DOM.
Now, there are two worth mentioning issues with runtime stylesheets:
Runtime library overheadAs mentioned before, additional code needs to be shipped to the browser to inject and update the stylesheets at runtime. The size of this code could vary quite a lot between 1KB
and 18KB
minified, based on my analysis.
It's essential to keep in mind that the runtime code is required with CSR (Client-Side Rendering) and SSR (Server-Side Rendering) as well.
Duplicated styles with SSRWhen rendering on the server, CSS-in-JS libraries will also include the so-called Critical CSS along with the rendered HTML content. Critical CSS refers to all the styles required by the static HTML page generated by the server.
The caveat is that the critical CSS styles will be shipped twice to the browser: first with the HTML file and second with the JS bundle during re-hydration.
Static CSS extraction
A different method, implemented only by a handful of CSS-in-JS libraries, is to extract all the styles defined in JS files and generate a regular .css
file when building for production. This method allows us to include the styles as any regular CSS stylesheet in our document.
<!-- styles are extracted as static .css files -->
<link rel="stylesheet" href="styles.css" />
The JavaScript bundle will only contain the components and the application logic, since the styles have been extracted in their own .css
file. Also, there's no need for an additional runtime library since the browser can natively work with .css
files.
<!-- the bundle includes only the components and app logic -->
<script src="bundle.js"></script>
Without a doubt, extracting static CSS files will generate smaller bundles, thus shipping fewer bytes to the browsers. Also, with SSR, we don't require additional Critical CSS either, thus saving even more bytes.
Note that Critical CSS could also be used with Static CSS extraction as an additional optimization technique. Critters extracts critical CSS, similar to what CSS-in-JS libraries with runtime stylesheets do, while Critical extracts the critical-path (above-the-fold) CSS.
Static CSS extraction usually offers zero-runtime cost unless it requires some runtime code to handle dynamic styles. The end result is similar to using non-CSS-in-JS solutions like plain CSS, preprocessors, or CSS modules, sharing all the benefits and downsides of CSS stylesheets.
What about time?
Size is only one of the metrics that we can analyze, time being another relevant one. So, there's an important question that we need to ask:
Does a smaller page size imply faster loading times as well?
To answer this question, we'll have to look at the HTTP requests that a browser will send to the server to fetch the page's resources. There is a multitude of phases that each HTTP request has to go through, but two of them are relevant and play a significant role when studying page load performance:
- Time to First Byte (TTFB) which depends on networking conditions (DNS, TCP, SSL), latency, and server response;
- Content Download time which depends on the size of the resource and the bandwidth of the internet connection.
One of the limiting factors that we cannot control as software developers is the bandwidth of our users. Some of them might visit the page from a fast Wi-Fi, while others could be using a slow 3G mobile connection.
So, let's see how a loading waterfall chart looks for a typical but minimal web page, using both methods mentioned above. Usually, the HTTP requests for static resources will have similar TTFB, the big difference being reflected in the Content Download phase, based on the file size.
Since static CSS extraction implies a regular .css
file to be included as any other stylesheet in the document's <head>
, the loading waterfall might look familiar:
- the browser will download and parse the HTML file;
- during parsing, the browser will encounter the
.css
file reference, so it will make another request to fetch thestyles.css
file, an action that blocks rendering; - after downloading and parsing the
.css
file, if there are no other resources referenced in the<head>
, the browser can start rendering the page.
Presuming that we include the .js
files at the end of the document's <body>
, the browser cannot start painting the page until the entire .css
file is downloaded and parsed. So, static CSS introduces a render-blocking CSS resource.
In the case of runtime stylesheets, the loading waterfall is slightly different:
- the browser will download and parse the HTML file;
- then, it will download the JavaScript files, namely
bundle.js
andlibrary_runtime.js
.
If we include the .js
files at the end of the document's <body>
, and there are no other resources included in the <head>
section, then the browser can start painting the page on the screen as soon as the HTML document is parsed.
Excluding any other optimization techniques, the runtime stylesheets should offer faster First Paint metrics because it doesn't require an additional HTTP request for the external CSS file.
Large .css
files could become problematic in large applications because they tend to grow continuously, becoming very large and increasing First Paint metrics as time goes by. That's why we have various optimization techniques to overcome this problem:
- Inlining critical CSS eliminates that extra HTTP request and loads only a small part of the entire stylesheet upfront;
- Atomic CSS limits the amount of CSS code, having a logarithmic growing curve instead of a linear one.
It's worth mentioning that TTI (Time to Interactive) is nondeterministic, whether it's faster or slower, using one of the two methods. The involved variables are too many to draw a definite conclusion.
The example is oversimplified. Many details were left out for the sake of simplicity.
To conclude
Without a doubt, both runtime stylesheets and static CSS extraction are valid options to consider when choosing a CSS-in-JS library. But I feel that they naturally suit different purposes.
Runtime stylesheets seem to fit better with highly dynamic solutions such as Single Page Applications (SPAs), which usually make use of Client-Side Rendering (CSR):
- we don't need Critical CSS extraction, thus avoiding shipping part of our styles twice to the browser;
- they benefit from faster Paint Times to show loading indicators because the data is usually fetched Client-Side in such applications;
- most JavaScript frameworks that are usually used in SPAs offer trivial methods to lazy-load components, which also contain their styles.
Static CSS extraction, on the other hand, seems to fit better with more static solutions such as Static Rendering (SSG) and Server-Side Rendering (SSR):
- fewer bytes get shipped to the users because there's no overhead involved;
- changing JavaScript code while leaving CSS styles untouched will benefit from better caching.
It's important to understand that the above conclusions are only general guidelines. For example, some applications require hybrid rendering, combining SSR/CSR/SSG.
Also, there are other considerations to think about, especially with large applications:
- Do we want to optimize for first-time users or returning visitors?
- How often do we release new builds, thus invalidating the cache?
- What do we change more often: styles or components/application code?
- Do we have to optimize for (low-end) mobile devices?