Getting Content Painted under 2 seconds on the Mobile Web

Jacky Efendi
Jul 30 · 9 min read
Image for post
Image for post

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.
web.dev

Out of the web vitals, there are 3 core web vitals:

  1. Largest Contentful Paint, how long it takes for the largest contentful element to be painted.
  2. 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
  3. Cumulative Layout Shift, how much the layout of the page shift during user’s session
Image for post
Image for post

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.

Image for post
Image for post

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.

Image for post
Image for post

And thus, we started our journey to investigate this particular issue. Onward!


Identifying LCP

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.

Lighthouse

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.

Image for post
Image for post
A section in Lighthouse report about Largest Contentful Paint element

If you could not find this section, make sure you are on lighthouse version 6.0.0 or newer!

Chrome devtools

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.

Image for post
Image for post

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.

Image for post
Image for post

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.

Image for post
Image for post
The 3 approaches with their LCP timings

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.

Image for post
Image for post

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

Using <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.

Image for post
Image for post

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.

Image for post
Image for post
Example of using <picture> tag

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.

Image for post
Image for post

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.

Image for post
Image for post
Some of the images are not loaded 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.

Image for post
Image for post

In the future, we hope that Priority Hints could help us solve this problems better!

Result

Image for post
Image for post

Doing these improved our LCP by around 0.7s!

Image for post
Image for post
But, so far, we are not in the “good” territory yet

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.

Image for post
Image for post

Notice that in the image above, LCP only happens after some JavaScript execution is finished. Even though the network request for the image has already finished as denoted by the first vertical orange line. This is not something we expected to see. Since we are doing server-side rendering, the image should already exist in the initial HTML document and should be able to be painted without depending on JavaScript.

We then thought that we needed to find answers to these 2 questions:

  1. Did our JS resize the LCP element?
  2. 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.

Image for post
Image for post
Example of using ResizeObserver

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 DOMNodeInserted and DOMNodeRemoved.

Image for post
Image for post

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?

Svelte Hydration

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.

Image for post
Image for post
An issue on Svelte repo discussing about hydration

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.

Image for post
Image for post

We used it in our project by updating our package.json dependencies.

Image for post

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!

Image for post
Image for post

Result

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.

Image for post
Image for post

This final change finally brought us over to the green zone!

Image for post
Image for post
We are in the green!

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!


Summary

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!


Tokopedia Engineering

Story from people who build Tokopedia

Welcome to a place where words matter. On Medium, smart voices and original ideas take center stage - with no ads in sight. Watch

Follow all the topics you care about, and we’ll deliver the best stories for you to your homepage and inbox. Explore

Get unlimited access to the best stories on Medium — and support writers while you’re at it. Just $5/month. Upgrade

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store