A look at the pros and cons of some of the most commonly used methods for loading JS and CSS assets to understand impact they are likely to have on performance and UX (User eXperience).
On a recent project we were tasked with improving the performance of a suite of websites, here are some of the options we looked at along the way. There are loads of factors which influence website performance; images, fonts, lazy-loading, protocol, CDN, caching, GZIP, file sizes, CSS selector complexity, server response times to list a small fraction, for now I’m going to look at options for loading the JS and CSS assets.
To get the best performance out of our websites we need to decide which metrics are most important to focus on, for us this was the FMP (First Meaningful Paint) and DCL (DOMContentLoaded) events specifically. These were our main targets for improvement and both of these are a good place to start for most websites. Although, having a good understanding of what the business goals are on the site will help determine which metrics are most important to focus on.
Where we started from...
Both the JS and CSS were synchronous requests, the JS was packaged in two files, one prerequisite JS script tag in the head, and one main JS script tag located at the end of the body both with src attributes linking to files. The main JS contains quite a number of scripts that initiate calls to third party scripts via AJAX, these where run immediately. The CSS was packaged into one file and added via a link tag in the head. It contained all the media query styles including the print styles.
What to load and what to defer?
With the CSS we had to determine what is on the critical rendering path and what can be deferred (for more info see Google’s critical rendering path tutorial). As for the JS, this was more about identifying what, if anything, MUST be loaded up front, if nothing then all the JS can be deferred.
CSS
In an ideal world we need to extract the critical path CSS from the main body of the CSS, this is easier said than done though, to start with, what constitutes “above the fold" isn’t immediately obvious! But we have to start somewhere, so we created a critical CSS file and started adding styles that the browser would obviously need to render the page e.g. grid, header component, hero, main navigation styles etc... There are automated approaches like Critical or Penthouse, but for us these were not appropriate, instead we basically divided the main SASS file into two, critical and everything else, and put the appropriate import statements into the relevant file.
Once we had separated the critical path CSS, we kept this as a standard render-blocking link tag in the header, ideally we would like to inline it in a style tag in the head, but this is still on the TODO list at the moment! The remaining non-critical CSS could then be safely deferred.
High priority CSS: grid, above the fold element styles etc... Low priority CSS: any dynamic elements, below the fold element styles, media queries (more on media queries later) etc…
High priority CSS
Load Strategy | Example | Comment |
---|---|---|
Inline Style element in head |
|
This would be the best option as far as performance is concerned Pros: No render blocking request needed at all Cons: Cache management more complex. Tooling may be required to extract the critical path CSS. Implementation may require development to core system. Adds to page weight. |
Synchronous Link element in the head |
|
This is the default CSS implementation. Browsers won’t start to render any HTML after this tag until it's contents have been received and processed Pros: Easy to manage (version number, cache control etc...) Cons: Adds a render-blocking request, will negatively impact on all performance metrics |
Low priority CSS
Presuming we have extracted the critical path CSS and use one of the above methods to include it, the following methods can be applied to the remaining low priority CSS
Load Strategy | Example | Comment |
---|---|---|
Synchronous Link element positioned at the end of Body element |
|
Having a normal link tag just before the closing body tag won’t block rendering, but probably less efficient than having an async request in the head. Pros: Easy to implement, not render-blocking, no JS shenanigans needed Cons: Requested with highest-priority, more efficient options exist For an indepth look at CSS delivery see this CSSWizardry article |
Preload rel="preload" and as="style" attributes For more see Moz Preload |
|
Requests the stylesheet asynchronously high-priority, critically this is a non-render blocking request unlike normal rel="stylesheet" link so this could be put in the head and likely get better results that the default link at the bottom of the body tag Cons: Requires JS, and JS polyfill to work due to patchy browser support see Can I Use, preload? |
Alternate stylesheet rel=”alternate stylesheet” For more see Moz Altstyles w3 Alternatives |
|
Alternative stylesheets are usually requested asynchronously low-priority. Some browsers offer a menu of alternative styles if they exist, this method removes the ‘alternate’ keyword from the rel attribute when the CSS loads. This is likely to get better results than the default link at the bottom of the body tag. Cons: Requires JS to work |
The media="none" hack |
|
This is likely to behave similar to ‘alternate’ a low-priority, non-render blocking request. This method is often used as a fallback for the previous rel=“preload" example for browsers that don’t support the ‘preload’ value on the ‘rel’ attribute. Cons: Requires JS to work. |
By Media Query Multiple Link elements with separate media query values |
|
Instead of having a single CSS file containing multiple ‘@media’ declarations, split the CSS into different files based on the media query and have separate link elements in the HTML for each file. This allows the browser to download the CSS files that match the current media query immediately, whilst still fetching the other files with a low priority non-blocking request. With HTTP/1 individual requests had quite an overhead so reducing the number of requests was desirable. In HTTP/2 this isn’t such a concern. Pros: good performance, good user experience Cons: may be difficult to implement, change to tooling around how CSS is built. |
With the critical path CSS separated, the page is now a little vulnerable to displaying unstyled content to the user if they scroll down before the main CSS has loaded. To mitigate this our default approach is to hide these elements in the critical path CSS and then unhide them in the main CSS. This prevents unstyled components displaying at all, true it’s a bit of a sledge hammer approach, but we still have the option to fine tune the pre-load state in future with skeleton place holders or similar. Using JS script to load low priority CSS For the link tag rel attribute ‘preload’ polyfill you need to write a fallback or use a tried and tested script such as CSSRelPreload.js to provide browser support, or if you want to defer loading the CSS until page OnLoad then write a small script (remembering to retain a no-script version so whatever happens the browser will always download all the CSS).
JavaScript
We already started with a distinction between what MUST be loaded in the head and what could be deferred. As with the CSS it would be great to inline the ‘must have’ JS with a script tag in the head but this would require development work we didn’t have bandwidth for at the time, so we put this task on the ever growing TODO list! The main JS was a synchronous script tag located just before the closing body tag.
Essential to load in the head: e.g. analytics, polyfills etc... Low priority*: e.g. main js, third party scripts
*Low priority does not mean ‘non-essential’, these scripts may well be an integral part of the website, we still want to load these in the most efficient order to get the best performance possible
High priority JS
Load Strategy | Example | Comment |
---|---|---|
Inline Inline script element in head |
|
Pros: no http request needed, runs immediately Cons: Versioning and cache management more complex. Maybe difficult to implement. Adds page weight if huge. |
Sync Script element with ‘src’ attribute in the head |
|
Pros: Easy to manage (version number, cache control etc...) Cons: Adds a render-blocking request |
Low priority JS
Presuming high priority JS has been loaded using one of the above methods, the following methods can be applied to the remaining low priority JS.
Load Strategy | Example | Comment |
---|---|---|
Async Script element with ‘async’ attribute in the head |
|
In terms of a script tag in the source HTML I can’t think of any use-case where ‘async’ would be better than ‘defer’. Adding async is unlikely to have anywhere near as much improvement to any metric when compared to defer. NB - Async is very useful when programmatically requesting new JS/JSON files |
Defer Script element with ‘defer’ attribute in the head |
|
This will cause the JS to be run when HTML parsing is complete. Also the browser will run scripts in the order they are specified in the HTML. Adding this will usually improve FMP and DCL events |
Sync Script element at bottom of body element |
|
A fairly common paradigm, adds a blocking requests at the bottom of the page where it’s least obtrusive Similar to 'defer', but probably less efficient. |
Onload Using a script to load the JS file OnLoad |
|
Loading low priority and third-party scripts on page OnLoad event will most likely lead to a good improvement to pretty much all the performance metrics. Considerations; are you visually showing controls that require JS to work? If so these need to be disabled until the JS has initialised. This sounds obvious, but when you move a script from loading sync in the head to run OnLoad you may find there are a few controls than never needed a disabled state that suddenly do! |
We used a small script to load the main JS when the OnLoad event fires. Also, we added a feature in the CMS giving us the ability to switch this between sync, defer and OnLoad. This ability was added for future proofing, if any scripts needing to run before page load were added or if there were any unforeseen issues with running the main JS after page load, admins could change this setting to ‘defer’ or even back to ‘sync’ in the CMS.
Round-up
So all in all we didn’t make that many changes, now the main CSS and the main JS are added when the OnLoad event fires, the overall performance improvements where roughly; Full page load by 22%, DCL improved by 26% and FMP improved by 36% which helped us to hit our performance targets. Also, harder to measure, but the general user experience was improved as we were prioritising the things the user was most likely to interact with.
Historically, relying on the OnLoad event to fire could be a bit risky, the danger being that a third-party script will dynamically request a CSS or image asset which is on a server somewhere that fails to respond, the browser will then only fire the OnLoad event when the request eventually times out. By loading all the third-party scripts after the page OnLoad event has already fired helps to mitigate this issue.
We also have the improvements in our TODO list, such as inlining the critical CSS and head JS, which would improve the FMP metric. Also, splitting the CSS into individual files by media query and adding them is separate link elements would likely improve the user experience. All in all, we are in a better place, now we are meeting our performance targets, the pressure is reduced somewhat and going forward we can focus on improving the user experience whilst maintaining the current performance.