How we achieved rendering OneShop webpages under 2 seconds

Tarun Goyal
Deutsche Telekom Digital Labs
10 min readOct 16, 2020

It’s been a couple of months since I’ve joined Deutsche Telekom Digital Labs. The team here is passionately and aggressively working on digitizing customers’ needs through their great products OneApp, OneShop, OneTV, HGW, and many more. These products are one-stop solutions for all the telecom related needs.

One Shop

It is a centrally built E-commerce platform that complies with the requirements of all 12 National Companies (NatCos) which have different business portfolios and capabilities in Europe. It is currently operational in Czech, Poland, Hungary, Slovakia, and Macedonia.

As it is a central application for multiple Natcos, the application has been growing with new incoming features that bring with itself a new set of challenges to deal with.

Here at Gurgaon Development center (GDC), we brainstorm upon all the challenges we face and make strategies to mitigate them to deliver an awesome quality product. Below are some of the problems we faced and how we mitigated them.

Problem Statement

We all know performance is an important factor for a web-based application. So, we ran multiple tests across our E-commerce platform (One Shop) on page-insights and found some prominent issues, mainly due to the low scores of a couple of metrics. Our public pages were not meeting expectations.

We are talking about mobile performance here because the majority of our users use mobile devices.

Home page
Product catalog
Tariff listing

As we can see from the above screenshots, the scores are not that great, and we found multiple low performing metrics upon which we need to brainstorm.

Here are the hard internet facts:

47% of people expect your site to load in less than 2 seconds.

40% will abandon it entirely if it takes longer than 3 seconds.

You get a little bit more room to move with mobile visitors, but not much.

85% of internet users expect a mobile site to load as fast or faster than on their desktop.

What could lead to the poor performance of the OneShop site?

LCP /SPEED INDEX (a measure of visible content)

As image banners or carousels mostly cover the largest content on a webpage, their performance depends on factors like:-

  • Large and uncompressed Images
  • Lazy loading of Images already in the viewport
  • Client-Side Rendering and Render-blocking JavaScript, CSS
  • Slow server response times

TBT / TTI(a measure of Unresponsiveness of main thread)

  • Third-party code
  • The enormous payload of javascript
  • Unused Code
  • Large JavaScript execution time and main thread work
  • Expensive Manipulations
Photo by Markus Spiske on Unsplash

The one thing common to each metric is the size of javascript being sent over and executed on the browser. Big script bundles lead to high javascript execution time and main thread blocking but not particularly, which affects all the metrics negatively. Therefore, optimizing what we deliver to the browser is important.

Find out how we get into the solution and optimized OneShop webpages

LCP /SPEED INDEX ( Visually perceived performance)

  • Sending compressed and properly size images — Image extensions like webp, jpeg provide a good level of compression over other formats. Based on the browser’s support we send images to users. You can use third-party services for this or can use AWS Lambda to make your service.
  • Disable lazy loading of the images available on the first visible viewport. This will quickly render the largest content and thus improve the LCP & speed index.
<!-- visible in the viewport -->
<img src="product-1.jpg" alt="..." width="200" height="200">
<img src="product-2.jpg" alt="..." width="200" height="200">

<!-- offscreen images -->
<img src="product-4.jpg" loading="lazy" width="200" height="200">
<img src="product-5.jpg" loading="lazy" width="200" height="200">
  • Use an efficient image hosting service or CDN to serve images efficiently.
  • Server-side render your page to deliver content quickly and reduce render-blocking scripts by using async or defer attributes on your page.
  • Modern web browser techniques like Preload, prefetch can be used to prioritize resource fetching.

TBT (Main thread performance)

  • Code Splitting / Lazy loading
  • Differential Loading
  • Redux Chunking
  • Webpack Optimisations
  • Pure Component / Memo
  • Brotli

Pre-requisites

  • Since we use React, so it has been taken as a reference. But these optimizations can be applied to any frontend framework of your choice.
  • WebpackBundleAnalyzer usage and it’s working.

Code Splitting

As the term implies, it is defined as splitting of code into smaller chunks, instead of packing the whole code into a monolithic bundle, we split out the code into multiple small files that are loaded on demand which improves the initial boot-up time of the page. Splitting can be route or component-based.

// Common Chunkimport Home from 'pages/Home';const Routes = () => {
return (
<Switch>
<Route path='/' exact component={Home} />
</Switch>
);
};
export default Routes;//lazy loaded route chunkconst Home = Loadable({
loader:() => import('/pages/Home'),
loading: Loader
});
const Routes = () => {
return (
<Switch>
<Route path='/' exact component={Home} />
</Switch>
);
};
export default Routes;

Note:- We are using react-loadable because it supports Server-side rendering out of the box. If you only need Client-side rendering React.lazy can be used along with Suspense as a fallback.

Pro tip: react-loadable-visibility is also a handy tool if you want to load your chunk when its section is visible in viewport. It is a wrapper around react-loadable.

We have seen an overall decrease in bundle size of approx 25% after this.

Differential Loading in React

It means having two separate bundles one for legacy and one for modern browsers. Based on the browser’s user agent, bundles are served, this allows your users to take advantage of smaller bundle sizes and fewer polyfills when using your application in a modern browser.

Tools Required

  • Webpack
    This will be our build tool, although the process will remain similar to that of other build tools, like Parcel and Rollup.
  • Browserslist ( npm i browserslist-useragent)
    With this, we’ll manage and define the browsers we’d like to support.

webpack.es-config.ts

// import statements & pluginsclientEsConfig = {//...output: {
filename: 'es-[name].[chunkhash:8].js',
chunkFilename: 'es-[name].[chunkhash:8].js'
},
module:{
//...
rules: [
// Rules for JS / JSX
{
test: reScript,
loader: 'babel-loader',
options: {
presets: [
[
'@babel/preset-env',
{
targets: {
esmodules: true // for modern browsers
},
modules: false,
useBuiltIns: false,
}
],
//...
],
},
plugins: [
//...
new WebpackAssetsManifest({
output: `${BUILD_DIR}/asset-esmanifest.json`,
}
};
export default clientEsConfig;

server.ts

import { matchesUA } from 'browserslist-useragent';app.get('*', async (req, res, next) => {
const userAgent = req.headers['user-agent'] || '';
const isModernUser = matchesUA(userAgent, {
env: 'modern',
allowHigherVersions: true,
});
//...
}

index.ts

<script src=`${isModernUser?'es-bundle':'legacy'}.js`> </script>

Here, we have our separate webpack config for modern browsers which would output modern script bundles with es- prefix based on the babel configuration and we are using browsers list-useragent npm to identify the browser and server our bundle accordingly.

We are using ReactLoadableWebpackPlugin & WebpackAssetsManifest plugins to extract assets in the react-loadableesmodules.json and asset-esmanifest.json files and later insert them in index.ts

On an average, we have seen a reduction of approx 10% in our bundle sizes.

Redux Chunking

We have done our route & component-based splitting but there’s one thing we never split out, redux related things which are still is in RootState. Reducer, Sagas related to that route are still being injected without any need.

Redux chunking is chunking the redux store according to our requirements to decrease the size of the application’s entry point.

configureStore.ts

// ... Import Statementsfunction createSagaInjector(runSaga) {
const injectedSagas = new Map();
const isInjected = (key: string) => injectedSagas.has(key);
return (key, saga) => {
if (isInjected(key)) {
return;
}
const task = runSaga(saga);
injectedSagas.set(key, task);
};
}
function createReducer(asyncReducers = {}) {
return combineReducers({ ...rootReducer, ...asyncReducers});
}
// Configure store
const composeEnhancers = compose;
export const configureStore = (initialState) => {
const sagaMiddleware = createSagaMiddleware();
const enhancer= composeEnhancers(applyMiddleware(sagaMiddleware));
const store = createStore(createReducer(),{},enhancer);
store.asyncReducers = {};
store.injectReducer = ( key, asyncReducer) => {
if (!store.asyncReducers[key]) {
store.asyncReducers[key] = asyncReducer;
store.replaceReducer(createReducer(store.asyncReducers));
}
};
store.injectSaga = createSagaInjector(sagaMiddleware.run);
store.runSaga = sagaMiddleware.run;
return store;
};
let store = configureStore();export default store;// ... checkout/utils/dynamicStoreInjection.tsexport checkoutRootReducer from 'routes/checkout/store/reducer';
export checkoutRootSaga from 'routes/checkout/store/sagas';
// ... Routes.tsxconst Checkout = Loadable({
loading,
loader: async () => {
const module = await import('./dynamicStoreInjection');
store.injectReducer('checkout', module.checkoutRootReducer);
store.injectSaga('checkout', module.checkoutRootSaga);

return import('./routes/checkout');
}
});

We are injecting the reducer and saga functions before calling out the route scripts so that they become readily available in checkout components.

We have seen an improvement of the 5–10% in our final bundle.

Webpack plugins

  • IgnorePlugin prevents the generation of modules for import or require calls matching the regular expressions or filter functions.

for eg. Moment.js is shipped with all the locales by default and we can use this to remove all the locales from the bundle, only moment.min.js would be bundled. And, if you want to add some locale you can dynamically fetch that locale.

const webpack = require('webpack');
module.exports = {
//...
plugins: [
// Ignore all locale files of moment.js
new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/),
],
};
//Dynamic loading of locale
import(webpackChunkName:"[request]" */ `moment/locale/${locale}`);
moment.locale(locale); //set globally
const webpack = require('webpack');
module.exports = {
//...
plugins: [
// load `moment/locale/ja.js` and `moment/locale/it.js`
new webpack.ContextReplacementPlugin(/moment[/\\]locale$/, /ja|it/),
],
};
Moment.js

Other alternatives like date-fns, day.js, luxon, etc. can be used as well

We managed to reduce bundle size by 15% after removing various unwanted third party utilities.

Webpack Optimisations

Since Webpack 4, the majority of the optimizations are done already out of the box in production mode. Minification by TERSER plugin and optimization of a common chunk by splitChunks have been done with webpack by default. But you can have your custom rules set to optimize them even further.

  • Optimization
//... webpack config
optimization: {
splitChunks: {
chunks: 'all',
automaticNameDelimiter: '-',
cacheGroups: {
defaultVendors: {
name: 'vendors',
test: /[\\/]node_modules[\\/](!component-lib)[\\/]/
},
components: {
test: /[\\/]node_modules[\\/](component-lib)[\\/]/,
name: 'components'
}
}
}

Based on our use case we have made a separate bundle for our component library and other for third parties along with react.js files.

The main thing to note is the use of chunks: ‘all’, it enables webpack to optimize both sync and async module imports and place them accordingly in the final build. chunks: ‘initial’ and chunks:’async are other available options that can be used depending on your use cases for optimizations.

Here is a detailed article on these

https://medium.com/dailyjs/webpack-4-splitchunks-plugin-d9fbbe091fd0

Pure Component / React.memo

It gives a considerable increase in performance because it reduces the number of render operations in the application which is a huge win for complex UI and therefore advised to use if possible.

A React component can be considered pure if it renders the same output for the same state and props. For class components, that extend the React.PureComponent is treated as pure. It is the same as a normal component, except that PureComponents take care of shouldComponentUpdate — it does a shallow comparison of the state and props data.

React.memo is a higher-order component. It is similar to a PureComponent, but PureComponent belongs to the class implementation for Component, whereas “memo” is used for the creation of functional components.

//Class based component
class Welcome extends React.PureComponent {
render() {
return <h1>Welcome</h1>
}
}
//Functional Component
function CustomisedComponent(props) {
return (
<h1>Welcome</h1>
)
}
// Component won't re-render if same props value for "name" property
const memoComponent = React.memo(CustomisedComponent);

Brotli compression

Brotli vs Gzipped Comparison

https://imagekit.io/blog/what-and-why-brotli-compression/

With brotli, we have seen an improvement of 20% in our final bundle sizes.

Results

After all these optimizations we have managed to bring down our initial bundle sizes to almost more than 60% which helped us in improving our performance score on page insights.

Initial vs Final sizes

As optimization is a progressive step, we have done some amazing work in improving the performance numbers and are still in process of increasing them even further. Here are some screenshots:-

Home page
Product catalog
Tariff listing

We have significantly improved our numbers from ~20 to 60+ but that’s not all. We are still working aggressively to reach out to the target score of 90+ for every public page. We have got a good start and is now looking to convert it into much bigger success.

Maintenance

  • To keep ourselves on the right track and to make this a standard practice, our Frontend and DevOps teams are working together to build an automated pipeline where a commit or PR request won’t be merged if the budget of our entry point exceeds its defined threshold.
  • Regular sync ups among team members on performance to incorporate the latest things into our Ecosystem.

Further Read:-

  1. Reselect which makes expensive repeated calculations an easy task.
  2. Immer.js which is used for immutability in react apps.

I hope we have covered some interesting areas that provided good insights on the optimizations we can do in our applications to increase performance.

We are a team of Frontend developers working on OneShop E-commerce at Deutsche Telekom Digital Labs.

Thank you for taking the time to read out the whole article. Stay tuned to this space for more tech articles

--

--