Forest header image

Symfony Finland
Random things on PHP, Symfony and web development

Inlining critical CSS for Symfony apps

Inlining CSS is a technique that allows for improved first page load speed experience. In short you serve a snippet of your full CSS that is required to view the part before the fold, visible in the view port on load. The full stylesheet is loaded asynchronously with JavaScript.

I won't spend time explaining why you should do this, for this I recommend reading this: How and Why You Should Inline Your Critical CSS. You can craft the critical CSS by hand, but it is often more practical to use online services or libraries to extract it from your main CSS. Especially if you plan to extract it for all views in your app.

One popular tool for extracting critical CSS is Penthouse. It is available as an online version to quickly extract styles for a specific url. The JavaScript library is also available via NPM. For Symfony apps already using Webpack Encore adopting Penthouse is fairly straightforward and critical CSS extraction can be automated.

First install the Penthouse CLI tool and add it as a dev dependency:

yarn add --dev penthouse-cli

Now run penthouse-cli with parameters to extract critical CSS from a full file, e.g.

./node_modules/penthouse-cli/bin/penthouse-cli --url=https://example.com --css=public/css/full.css --output=public/css/critical.css

The above will download the URL, apply full.css and output critical parts for that view in critical.css. Adding this scripting will work for any CSS input and output. with a shell script you can easily create many different files for individual views (e.g. one for front page, and one for contact feedback form and another for documentation).

You can embed running this in your Symfony app for example by extracting the above to a script and then running that always with Composer install via composer.json:

"scripts": {
    "auto-scripts": {
        "cache:clear": "symfony-cmd",
        "assets:install %PUBLIC_DIR%": "symfony-cmd",
        "./bin/generate_critical_css.sh": "script",
    },
    "post-install-cmd": [
        "@auto-scripts"
    ],
    "post-update-cmd": [
        "@auto-scripts"
    ]
},

Now the critical CSS is automatically generated. Next we need to include the inline CSS and async loading in your Twig templates. One easy option to include the Twig source function that will import a file in place. To make the critical CSS available for Twig, output it to your templates directory, e.g. templates/css/critical.css

Then use the source function to import the critical CSS snippet in place to the head section in your main template (e.g. templates/base.html.twig):

<style>
{# yes, this is CSS in templates/ #}
{{ source('css/critical.css') }}
</style>

With this in place we've now got our critical styles in place inline so they won't be blocking resources for page rendering, but we still need to import the main CSS to be able to view the full page. Instead of including them using a traditional link tag in the head, we will use the following JS snippet from sitelocity critical css generator:

<script>
    var cb = function() {
        var l = document.createElement('link'); l.rel = 'stylesheet';
        l.href = '/css/main.css';
        var h = document.getElementsByTagName('head')[0]; h.parentNode.insertBefore(l, h);
    };
    var raf = requestAnimationFrame || mozRequestAnimationFrame ||
        webkitRequestAnimationFrame || msRequestAnimationFrame;
    if (raf) raf(cb);
    else window.addEventListener('load', cb);
</script>

To replace the static CSS entry with dynamic entries (cache busting, etc.) you can use the encore_entry_css_files helper to get raw urls instead of link tags. If package everything to a single file in prod, then this in your <head> should do the trick:

{% for entry in encore_entry_css_files('public-site') %}
<script>
    var cb = function() {
        var l = document.createElement('link'); l.rel = 'stylesheet';
        l.href = '{{ entry }}';
        var h = document.getElementsByTagName('head')[0]; h.parentNode.insertBefore(l, h);
    };
    var raf = requestAnimationFrame || mozRequestAnimationFrame ||
        webkitRequestAnimationFrame || msRequestAnimationFrame;
    if (raf) raf(cb);
    else window.addEventListener('load', cb);
</script>
{% endfor %}

That's it. Your Symfony app now creates and inlines the critical CSS file and asynchronously loads the full CSS file. It does require that you have a live version of your site somewhere (our example used example.com). And of course there is likely a lot of things you could do to improve the it. But this is a start and beats copy-paste.


Written by Jani Tarvainen on Friday June 5, 2020
Permalink -

« Moving a Symfony app using Doctrine ORM from PostgreSQL to MySQL - eZ Platform is now Ibexa DXP: A Digital Experience Platform on Symfony »