A Tokopedia Mobile Web Performance Case Study

Aatif Bandey
May 21 · 10 min read
Image for post
Image for post

is an Indonesian technology company with a mission to democratize e-commerce through technology. It is one of the largest e-commerce platforms in Indonesia.

Tokopedia has various verticals where users can buy . We have a as well as a site, where most of the traffic comes on the mobile web.
A lot of users visiting the mobile site have low-end phones with 3G network quality, for the best user experience, our Tokopedia Engineering invests heavily on web performance.

The problem at hand

The sale is a core part of any e-commerce. Since 2018, Tokopedia has been doing a major sale event every year in the month of Ramadan. We call it Apollo. You can compare it to the Great Indian Festival or Big Billion day done by Amazon & Flipkart in India during Diwali or Alibaba’s Singles Day or In the US the Black Friday Sale.

Web pages used in Tokopedia’s sale are called as Discovery pages. It’s like an entry point to sales/flash sales where customers learn about products on sale & offers/discounts etc. These pages are super dynamic in nature i.e. each sale page can have different layout/styles. So, these pages should be fast and should quickly respond to user clicks & gestures.

Before I start sharing about the performance improvement that we did, here is the score that we had, performance score ranges from 67~71(as per )

Image for post
Image for post
Source: Tokopedia’s Lab data

As per the above performance score, if you have a 3G connection on your phone, your page will take a minimum of 5–6 seconds to load and interact.
To improve the performance, we set our targets to achieve a score of 80+.

The tech is primarily, , (for the state management), and (to write CSS).

Image for post
Image for post

Legacy Code

Most of the time when we develop new features, we don't notice important areas like DOM size, painting time, the main thread, etc due to bad plannings or strict timelines.

Before diving further, I would like to show, how a discovery page looks like.

Image for post
Image for post

The above image is for one of the discovery pages used in Tokopedia mobile, the content of this page is dynamic and is controlled via an internal dashboard that is used by the business & product team.
It has a banner, category navigation, product cards, slider, etc we call them components.

The backend API provides us the list of components that needs to be rendered on the pages, the components are more than 20 on any discovery page.

Also, these components can have a child component too, so to paint all n number of components and their child can increase main thread work, DOM size, time to interaction.

Reduce DOM size

As stated above we have too many components on the page and we wanted to reduce the DOM size and paint time.
We took an approach, not to paint every component which is not in the viewport. We added an intersection logic to determine the components in the viewport.

Let’s check the implementation

Image for post
Image for post

The above image is from the sale page, this page has around 25 components, and as you can see we have just 5 components in the viewport at first load.

How did we reduce DOM size?
We took an approach to paint only 8 components at the first load for any discovery page and as soon as user will scroll, we will set the value of the intersected flag to true

Here is the implementation:

Image for post
Image for post

As you can see in the code snippet, we exit for loop if the index (k) is greater than the threshold value (+2) and on window scroll, we update the intersected flag to true.

Image for post
Image for post

Live example of the HTML paint before and after scroll.

Image for post
Image for post

The DOM elements on the left are far less than the right snapshot, we have almost 8 HTML elements at first view, and when users scroll all the required DOM elements are painted.

The same intersection concept was also implemented for components using a horizontal scroll.

This helped us to increase the performance score index by 8 points from 62 ~ 68

What can be done more?

With Google’s Page Speed Insight auditing, we got some suggestions like preconnect, defer images, etc. Preconnect was missing for some of the URLs.

What is preconnect?

Pre-connect is enabled on a web page by adding rel=preconnect an attribute to a <link> tag, It informs the browser that your page intends to establish a connection to another domain. Know more about preconnect, read .

Image for post
Image for post

You can add a pre-connect on your HTML page by adding a simple line of code

Image for post
Image for post

Learn from your mistakes

We added pre-connect tags for the missing URLs on our page, but after adding the same we didn't see any improvements on the page.

Because we didn’t put the pre-connect tags at the right place.

Image for post
Image for post

If you can see in the image, preload links are placed before pre-connect which eventually means pre-connect never worked for us.
Know about preload .

So we corrected the order for pre-connect tags put them on top,
the pre-connect tags helped us to reduce almost 180~ms for one connection

Results from the .org

Below are stats from webpagetest.org, you can see there is no DNS lookup, initial connection, etc when using pre-connect.

Image for post
Image for post

Reduce Image size

Our discovery pages are full of images, banners, sliders, etc, we were using jpeg, png and the cost of each image was very expensive, creating performance issues.

We decided to migrate the stack to WebP.

Make use of WebP

Every browser doesn't support WebP, like safari doesn't support,
There are two ways to make use of WebP.

Skip server rendering for images
One of the approaches is to detect the browser and fetch webP image or jpeg/png image based on the browser.

Example: Say your SSR page has a component with an <img> tag and the path for the image is when you build this
the component on the server it will fetch the WebP image by default.
<image src=""/>

The above code will work fine on chrome but on safari, it will break.

We are not sure about the user’s browser while rendering the component on the server, the possible solution is to wait and detect the browser on the client which means not to render the image component on the server.

If you want to load the WebP images, you need to render your image component on the client-side or lazy the load the component.

WebP can increase your if not properly used.

As shared above, if you are rendering the component on the client, you will have to wait for the browser to detect WebP support, and then it will start to paint it, which can increase your time to interact in most of the cases.

Let me try to explain with an example — when you have a Server-side rendered page you build some of the components on the server while some on the client.

Image for post
Image for post

The image on left is showing the content build from the server, and the image on the right showing the final layout after client rendering.

If you don’t know to check what is render from the server just Open developer tools in Chrome → Network tab → Doc and click preview

As you can see in the image, we are not rendering our slider component from the server when the page reaches to browser the slider is rendered as shown in the right section of the image.

After the server has rendered the HTML, the page shifts some of the components to bottom on the client since it has to paint slider on top (as shown in the right section) which will cause repaint of your DOM that will increase your Time To Interactive (TTI).

Check for WebP support on SSR

There’s another approach, Do you know we can check the request headers of the document to detect whether it supports WebP or not?
This approach is called .

Image for post
Image for post

We have a request header called accept and it tells us the browser acceptsimage/webp.
This image is taken from the chrome browser and if you open safari the image/webp is not available under accept headers.

We can read these headers on SSR by adding a simple line of code

Image for post
Image for post

So here global.webpSupport is just a flag to update our component that they can fetch webP or jpeg.
With the above approach, you don't need to wait for the image component to be rendered on the client you can build the image component on SSR which can give a boost to you TTI

Fetch smaller images from the server.

We use a lot of images on the discovery, we have a slider, banner, tabs, etc all these components use images, but images we get from the server are large in dimensions than required.

Image for post
Image for post

The images in discovery are served from the server we don't have static images, API provides us the path of the image.

As you can see in the image, each row has 2 two banners and the width of each banner image is around 400px, which means 800px on each row.
The maximum width for any device is around 414px which is nexus 5, and we were rendering two images each 400px.

We wanted to improve the load time, and the plan was to fetch smaller images, so we wrote a utility that will replace the image URL coming from API with the new image URL containing the preferred dimension required.

Image for post
Image for post

Example: If we have two banners in a row we will fetch images of size 250px, if we have one banner will fetch the images of size 430px.

This helped us to reduce 30% of image size on the page, sharing one of the samples.

Image for post
Image for post

you can easily spot the difference between the size for the same image when fetching images of smaller dimensions.

Note: The backend should support images of different sizes

Convert your Class Components into functional components

Apart from the above optimizations, there was still some room for improvement like converting your class components into functional components.

We had some of the components in the discovery that were still using class components, so we planned to migrate them into functional components and we saw noticeable differences in chunk size.

Image for post
Image for post

Here are the stats for a basic class component vs functional component

Image for post
Image for post

Ship less javascript on the first load.

Our discovery~chunk was around 21KB(gzipped) as we were shipping some extra chunks on the first load.

Most of the components were not used at first load like a share button, toaster, back to top button, internet~connection, etc.

We decided to load all these components on demand and when the user will perform some action we fetch those required components.

With the lazy loading, our discovery chunk was now reduced to 14 KB.

Showtime

After working on the above optimizations and much more, the result was overwhelming

Image for post
Image for post

Finally achieved the Performance Score we targeted for, on mobile with a fast 3G connection, we are also evolving and aiming the similar changes in other important pages like , , etc.

Last but not the least, I would like to introduce , one of the engineers at Tokopedia’s Web Platform Engineering who was riding with me on this performance journey and thanks to (Engineering Manager Web Platform) for reviewing and supporting us throughout the journey.

I hope you enjoyed reading this article.

Thank you.

P.S. 👋 Hi, I’m Aatif! If you liked this, follow me on and share the story with your developer friends.

Disclaimer: All the Performance Score shared are based on Lighthouse V5, on 28th May 2020 V6 is live on , so scores may vary from the mentioned date.

Further Reading

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