Web Performance, Keeping your Lighthouse Score 100.

The quality of the experience of websites is crucial to achieving the business goals of your website and the satisfaction of your visitors.

Adobe Experience Manager (AEM) is optimized to deliver excellent experiences and optimal web performance. With the Real Use Monitoring (RUM) operations data collection, information is continuously collected from field use and offers a way to iterate on real use performance measurements without having to wait for the CRuX data to show effects of code and deployment changes. It is common for field data collected in RUM to deviate from the lab results, as the network, geo-location and processing power for real devices are much more diverse than the simulated conditions in a lab.

The Google PageSpeed Insight Service is proven to be a great lab measurement tool. It can be used to avoid the slow deterioration of the performance and experience score of your website.

If you start your project with the Boilerplate as in the Developer Tutorial, you will get a very stable Lighthouse score on PageSpeed Insight for both Mobile and Desktop at 100. On every component of the lighthouse score there is some buffer for the project code to consume and still be within the boundaries of a perfect 100 lighthouse score.

Testing Your Pull Requests

It turns out that it is hard to improve your Lighthouse score once it is low, but it is not hard to keep it at 100 if you continuously test.

When you open a pull request (PR) on a project, the test URLs in the description of your project are used to run the PageSpeed Insights Service against. The AEM GitHub bot will automatically fail your PR if the score is below 100 with a little bit of buffer to account for some volatility of the results.

The results are for the mobile lighthouse score, as they tend to be harder to achieve than desktop.

Why Google PageSpeed Insights?

Many teams and individuals use their own configurations for measuring Lighthouse scores. Different teams have developed their own test harnesses and use their own test tools with configurations that have been set up as part of their continuous monitoring and performance reporting practices.

The performance of a website impacts its rankings in search results, which is reflected by the Core Web Vitals in the crUX report. Google has a great handle on the relevant average combinations of device information (e.g. screen size) as well as network performance of those devices. But in the end, SEO is the ultimate arbiter of what good vs. bad web performance is. As the specific configuration is a moving target, performance practices should be aligned with the current average devices and network characteristics globally.


So instead of using a project specific configuration for Lighthouse testing, we use the continuously-updated configurations seen as part of the mobile and desktop strategies referenced in the latest versions of the Google PageSpeed Insights API.

While there may be additional insight that some developers feel they can collect from other ways of measuring Lighthouse scores, to be able to have a meaningful and comparable performance conversation across projects, there needs to be a way to measure performance universally. The default PageSpeed Insight Service is the most authoritative, most widely accepted lab test when it comes to measuring your performance.

However it is important to remember that the recommendations that you get from PageSpeed Insights do not necessarily lead to better results, especially the closer you get to a lighthouse score of 100.

Core Web Vitals (CWV) collected by the built-in RUM data collection play an important role in validating results. For minor changes, however, the variance of the results and the lack of sufficient data points (traffic) over a short period of time makes it impractical to get statistically relevant results in most cases.

Three-Phase Loading (E-L-D)

Dissecting the payload that's on a web page into three phases makes it relatively straight-forward to achieve a clean lighthouse score and therefore set a baseline for a great customer experience.

The three phase loading approach divides the payload and execution of the page into three phases

Phase E: Eager

Before anything happens, it is important to note that the body must be hidden (with display:none) to make sure no images start downloading and to avoid initial CLS.

In the eager phase, the first action is to “decorate” the DOM: the loading sequence makes few adjustments, mainly adds CSS classes to icons, buttons, blocks and sections and creates the auto-blocks. See the Markup, Sections, Blocks, and Auto Blocking page for more details on the resulting markup.

The body can then already be displayed, considering that sections are not loaded yet and remain hidden.

Then, the full first section is loaded with a priority given to the first image of this section, the “LCP candidate”. In theory, the fewer blocks the first section has, the faster LCP can be loaded.

Once the LCP candidate and all blocks of the section are loaded, the first section can be displayed and the fonts can start loading asynchronously.

This ends the eager phase.

LCP

In general, the LCP is the “hero“ image displayed at the top of a page. It is crucial to make sure this image is loaded and displayed as soon as possible in the loading sequence (see the Eager phase).

Everything that's needed to be loaded for the true LCP to be displayed must be loaded. In a project, this usually consists of the markup, the CSS styles and JavaScript files.

In many cases the LCP element is contained in a block, where the block .js and and .css also have to be loaded.

It is a good rule of thumb to keep the aggregate payload before the LCP is displayed below 100kb, which usually results in an LCP event quicker than 1560ms (LCP scoring at 100 in PSI). Especially on mobile the network tends to be bandwidth constrained, so changing the loading sequence before LCP has minimal to no impact.

Loading from or connecting to a second origin before the LCP occurred is strongly discouraged as establishing a second connection (TLS, DNS, etc.) adds a significant delay to the LCP.

There are situations where the actual LCP element is not included in the markup that is transmitted to the client. This happens when there is an indirection or lookup (for example a service that’s called, a fragment that’s loaded or a lookup that needs to happen in a .json) for the LCP element.
In those situations, it is important that the page loading waits with guessing the LCP candidate (currently the first image on the page) until the first block has made the necessary changes to the DOM.

There are other situations where the content contains 2 hero images, one for desktop, one for mobile. Same as above, it is important to make sure that the correct image is considered as the LCP candidate and the “hero” block might need to be adjusted to remove the unnecessary image from the DOM (remove the desktop image on mobile devices or vice versa) to not load a bandwidth consuming image or even worse, load the unnecessary image first as the LCP candidate.

Finally, the LCP can be something other than an image, a video, a long text… For all those cases, a deep understanding of the loading sequence and how the LCP candidate is computed is necessary to make the correct optimizations.

Phase L: Lazy

In the lazy phase, the portion of the payload is loaded that doesn't affect total blocking time (TBT) and ultimately first input delay (FID).

This includes things like loading the next sections and their blocks (JavaScript and CSS) as well as loading all the remaining images according to their loading="lazy" attribute and other JavaScript libraries that are not blocking. The lazy phase is generally everything that happens in the various blocks you are going to create to cover the project needs.

In this phase it would still be advisable that the bulk of the payload come from the same origin and is controlled by the first party, so that changes can be made if needed to avoid negative impact on TBT, TTI and FID.

Phase D: Delayed

In the delayed phase, the parts of the payload are loaded that don't have an immediate impact to the experience and/or are not controlled by the project and come from third parties. Think of marketing tooling, consent management, extended analytics, chat/interaction modules etc. which are often deployed through tag management solutions.

It is important to understand that for the impact on the overall customer experience to be minimized, the start of this phase needs to be significantly delayed. The delayed phase should be at least three seconds after the LCP event to leave enough time for the rest of the experience to get settled.

The delayed phase is usually handled in delayed.js which serves as an initial catch-all for scripts that cause TBT. Ideally, the TBT problems are removed from the scripts in question either by loading them outside of the main thread (in a web worker) or by just removing the actual blocking time from the code. Once the problems are fixed, those libraries can easily be added to the lazy phase and be loaded earlier.

Ideally there is no blocking time in your scripts, which is sometimes hard to achieve as commonly used technology like tag managers or build tooling create large JavaScript files that are blocking as the browser is parsing them. From a performance perspective it is advisable to remove those techniques, make sure your individual scripts are not blocking and load them individually as separate smaller files.

The header and specifically the footer of the page are not in the critical path to the LCP, which is why they are loaded asynchronously in their respective blocks. Generally, resources that do not share the same life cycle (meaning that they are updated with authoring changes at different times) should be kept in separate documents to make the caching chain between the origins and the browser simpler and more effective. Keeping those resources separate increases cache hit ratios and reduces cache invalidation and cache management complexity.

Fonts

Since web fonts are often a strain on bandwidth and loaded from a different origin via a font service like https://fonts.adobe.com or https://fonts.google.com, it is largely impossible to load fonts before the LCP, this is why they are loaded right after. You might need to use font fallback technique to avoid CLS when the font is loaded.

Bonus: Speed is Green

Building websites that are fast, small, and quick to render is not just a good idea to deliver exceptional experiences that convert better, it is also a good way to reduce carbon emissions.