Google recently introduced Web Vitals, a collection of metrics that seek to provide unified guidance for quality signals for user experience on the web. This is a story of how we improved one of them: Largest Contentful Paint (LCP), on our mobile web homepage. Though, before we get into that, let’s have some introduction to Web Vitals.
What are Web Vitals?
Web Vitals is an initiative by Google to provide unified guidance for quality signals that are essential to delivering a great user experience on the web.
Out of the web vitals, there are 3 core web vitals:
- Largest Contentful Paint, how long it takes for the largest contentful element to be painted.
- First Input Delay, how long the delay between user first interaction with the web page and the point when the page responds to the interaction
- Cumulative Layout Shift, how much the layout of the page shift during user’s session
Web vitals are real user data collected from the field, as opposed to lab data collected from tools such as Lighthouse. At the time of this writing, the recommendation is to reach “good” on all core web vitals for the 75th percentile of data.
Now that we have an idea of what web vitals are, it is time to move on to the story!
Tokopedia Mobile Web Homepage
Our mobile web homepage is built using Svelte and Emotion. It is server-rendered, not statically rendered. It calls some APIs on the server side, generate a markup based on the API responses, and serialise some of the data so it can by re-hydrated on the client side.
We also wrote a story about how we built our mobile web homepage. If you are interested, you can read about it here: Achieving 90+ Mobile Web Performance at Tokopedia.
The bundle size is pretty small, but despite that, we noticed it had 4.1s LCP time, which is bad enough for it to be categorised in the “poor” category of LCP.
And thus, we started our journey to investigate this particular issue. Onward!
As it is with many things in life, it is hard to improve something without first knowing how to identify the thing itself. In this case, we need to know how to identify what our LCP element is. Fortunately, it is really easy to do so.
When you run lighthouse against your page, lighthouse will tell you about what the LCP element is in the page. You can then simply find the specified markup in your page to find the element.
If you could not find this section, make sure you are on lighthouse version 6.0.0 or newer!
With Chrome devtools, we need to first run a performance profiling on our page. The devtools will then show when the LCP timing happens in the timings section.
Clicking on the label will also show more information about what the LCP element is, and also highlight it in the viewport. Here, we can see the banner image is the LCP element of our page.
If you aren’t seeing this in your devtools, update your Chrome!
An idea: What if we render less stuffs on the server?
Rendering more elements on the server means that our server is doing more work (duh!). This leads to generally longer TTFB (time to first byte), and larger HTML response size. Also, if some of the server-side rendered HTML elements are images and they aren’t lazy-loaded, these extra images will be competing over bandwidth with the LCP image.
So, we tested 3 different approaches to see which one fares better.
Rendering less stuffs on the server while still keeping the LCP element improved our LCP by around 0.4s!
Doing full client-side rendering (CSR) in this case, worsened the LCP by a lot, because the browser needs to do a lot more works before painting the LCP element.
To summarise, on this part we improved our LCP by around 0.4s by rendering less stuffs on the server, which essentially resulted in:
- Faster TTFB
- Smaller initial HTML size
- Less images initially
Getting the LCP image ready earlier
We improved, a little bit, kinda. Next, we wanted to try getting the LCP image ready earlier so that it can be painted earlier as well.
Preload the image resource
<link rel="preload" />, we can tell the browser to load a particular resource (an image, in this case) earlier, with high priority, even before the browser found the
<img /> tag later in the HTML document.
While preloading is not supported in all browsers, it is pretty widely supported. According to caniuse, it supports around 92% of tracked mobile user agents.
You can read more about preloading at MDN: Preloading Content.
Use smaller image + WebP compression
Another thing we did was to use a smaller image with WebP compression to achieve smaller size. The way we achieve this is by using
<picture> tag for the LCP image, so it can detect WebP support and serve image according to the viewport width automatically.
In our case, simply using 500px WebP image in place of 800px jpg image allowed us to shave 50kB off the image size!
Lazy load other non-LCP images
We also noticed that we still have a lot of other images on our page. Looking at the devtools, we saw that all these images are competing over the bandwidth with the LCP image, causing the LCP image to be slower to load.
We decided to lazy load the other images, so that the bandwidth can be allocated for the LCP image download usage. The caveat with this though, is that some images will not be shown initially.
But, by doing so, we allowed our LCP image to be loaded faster. Think of it as a way to tell the browser to load more important resources earlier.
In the future, we hope that Priority Hints could help us solve this problems better!
Doing these improved our LCP by around 0.7s!
But, even with doing everything so far, we are still not in the “good” territory yet. We are close, but not there yet!
Discovery: Our JS seems to be blocking LCP
After some investigation (mostly using the devtools), we found that our JS seemed to be blocking the LCP of our page.
We then thought that we needed to find answers to these 2 questions:
- Did our JS resize the LCP element?
- Was the LCP element removed and re-inserted into the DOM?
Did our JS resize the LCP element?
If an LCP element was resized, the previous collected timings would no longer be considered as the LCP. We used
ResizeObserver to detect any resizes that happens on our LCP element.
Unfortunately, this wasn’t the culprit. We didn’t find any resizing happening to the LCP element.
Was the LCP element removed and re-inserted into the DOM?
If an LCP element is removed from the DOM, it will no longer be considered as an LCP element (this might change in the future though!), thus invalidating the previously collected LCP timings. To check for this, we relied on lesser-known DOM event, namely
After testing this, we found that this was indeed the case! But why? Why were we removing the element and re-insert it into the DOM?
Apparently, this is a known issue with how Svelte does hydration. Svelte remove nodes and re-insert them to the DOM. There is an issue on Svelte repo specifically discussing about this.
Seeing how this is an issue on the framework level, we weren’t sure on how to go around this. We tried monkey-patching
Node.prototype methods. It worked and improved our LCP, but it caused huge performance issues. Forking Svelte ourselves seemed to be a task that requires huge effort. Fortunately, there is a fork of svelte that specifically works around the hydration mechanism.
We used it in our project by updating our package.json dependencies.
Using this forked version of Svelte, we saw that our LCP now happens even before the JS execution finishes, which is what we wanted to see!
The LCP element is no longer depending on the JS execution, the LCP can happen much earlier in the page lifecycle! Visually, there are no noticeable changes though.
This final change finally brought us over to the green zone!
If you are reading this, please note that we do not recommend using an unofficial version of svelte on your production site. As for our case, we did our tests and confirmed everything was working according to our expectation. We felt the tradeoff was acceptable and went along with it.
Do not just trust what you read from tech articles, always do your own assessment as well!
Largest Contentful Paint (LCP) is a relatively new metric that is more simple to understand. LCP determines whether the main content of a page has been loaded yet, which in most cases reflect real user experiences. We want to let the LCP element be painted as soon as possible. Ideally, it shouldn’t be blocked by other stuffs, such as other network requests and JS execution as we have seen in this article.
The team at Google provides a lot of very helpful tools to help developers identify problems, testing and validating improvements. We find Lighthouse and Chrome devtools particularly useful! We also feel that investing time to learn these tools can be a very good investment for the future. Understanding more about how your UI framework of choice works internally can also really help you understand what is happening on your web-app.
As every web pages can be very different, our story is by no means 100% applicable for your case. Though, we do hope our story provided you with some insights that could help you make your own discoveries to share!
Web.dev has a lot of resources covering this topic, you will definitely find them useful!