How we improved user experience with a lite version of the upGrad’s learning platform

Arvind Narayan
Technology at upGrad
13 min readJan 7, 2021

--

Preface

Problem Statement:
Many of upGrad’s users in India do not have access to powerful devices or high bandwidth internet, which affects their browsing experience. upGrad learners with poor or unstable bandwidth connections or with low-end devices had reported issues with screen loading times and general responsiveness of the User Interface (UI).

This was an important problem for us to resolve as providing a smooth and seamless learning experience to all our users is a key goal for upGrad.

The upGrad learner platform UI was written in Backbone.js a few years ago, was heavy in size, lacked re-render performance, and was difficult to maintain and enhance. In order to solve the problem, we decided to revamp our architecture with a modern rewrite and build a lighter version of the platform — upGrad Learn Lite — which is much more forgiving of poor bandwidth and devices.

The outcome

  1. Lighthouse Scores:
Lighthouse comparison of old and new platform

As a result of the exercise, we increased the Lighthouse score from a marginal 1 to a staggering 92 on the home page of the application. In addition to improving the load times, we also converted the app to an easily extensible Progressive Web App (PWA).

2. Load time improvements

Load time comparisons

As you can see from the above image the legacy learner platform was heavy with 13.4 MB as the size of requests and takes 6.6s to load. The Learn Lite platform is 3.4 MB in size and takes 3s to load entirely. The best part is once the New App is loaded all the bundles and assets are cached the request size is just 1.7MB!

Where this improvement shines is in low bandwidth situations. The application provides faster initial loads and mimics the experience of an app where most static assets such as bundled CSS and JS files along with fonts, images, and icons are cashed and only API calls are required to fetch data.

3. Interactive Performance:

Interactive performance comparisons

As you can see there is a significant drop in scripting and loading times (by a factor of 5). The user experience of the new app is noticeably better; every interaction is slick and smooth and re-rendering is low due to usage of Reactjs and thanks to React’s Virtual DOM Reconciliation.

The rest of this blog describes technical details of the revamp (project codename Ares) of the legacy platform.

How we did it

First and foremost, the technology behind the entire Revamp (project code name — Ares)

Frontend:

Tooling

Infrastructure

Infrastructure based on Client View

Our architecture is plain simple and gets the job done. The application gets compiled and uploaded to AWS S3 after deleting the old build via our CI/CD Pipeline (Jenkins) and is delivered across geographies with the help of AWS Cloudfront CDN. For every release old cache gets purged.

The index.html file is never cached. This is intentional as any new updates are routed via new index.html

We use Imagekit (Image CDN with automatic optimization) for delivery of Images

Having no intermittent server reduced the Infrastructure Complexity and Increased the scalability whilst also drastically improving the Web App Delivery. 🚚

The Code

Now, let us deep-dive into React and general frontend principles talking about the changes we have done. Usage of React and its ecosystem by itself was a huge factor in boosting the app's performance.

Lazy Loading

Lazy Loading is a feature that enables you to load certain other bundles after some user interaction like changing the route. This means that you can chop your huge application into segments with some performance budgeting to meet your needs.
This will drastically reduce the initial load size of the App and load the application as per the User's usage of modules.

import React, { Suspense } from 'react';Import FirstComponent from './FirstComponent';
const OtherComponent = React.lazy(() => import('./OtherComponent'));

function MyComponent() {
return (
<div>
<Suspense fallback={<div>Loading...</div>}>
<Switch>
<Route to="/"> <FirstComponent /> </Route>
<Route to="/lazy-route"> <OtherComponent /> </Route>
</Switch>
</Suspense>
</div>
);
}

With help of React.lazy you can lazy load certain components We’ve had to lazy load the main module routes to fit into our performance budgets (less than 500kb). Suspense is a React component that would render some placeholder until the lazy-loaded component is fetched. You can have a placeholder component(loader, image and etc.,) in the fallback prop until the new bundle is loaded.

import React from 'react';
import { somefunction } from 'someLib';
function MyComponent() {

const lazyFun = () => {
import('lazy-lib').then((lazyLibraryFunction) => {
lazyLibraryFunction();
});
};


return (
<div>
<button onClick={() => lazyFun()}> Click to lazy execute
</button>
</div>
);
}

Not only did we lazy load components and routes but we have gone a step further to lazy load certain libraries that are only used conditionally.

export const changeLanguage = (locale) => (dispatch) => { import(`../locale/${locale}.json`).then(({ default: language }) =>{ 
dispatch({
type: 'SET_LANGUAGE',
language,
locale,
});
}).catch(() => {});
};

We are also lazy-loaded certain static JSON optionally. Like the example above where we lazy load the language JSON as per locale selected. This way the language files that were loaded were of the user’s choosing.

Usage of React Hooks:

React Hooks are a new addition in React 16.8 and they are certainly for the best. You can write pure Functional components and still manage to use State and Lifecycle via hooks courtesy of useState and useEffect. You might be pondering upon how the usage of hooks in functional components adds benefit with respect to performance.

Class-based component to JS (ES 2015)
Functional component to JS (ES 2015)

As you can see from the above Gifs functional components have nearly half the footprint of Class-based components simply due to the complexity of emulating classes and it’s a feature in JS as functions. This end of the day results in much smaller bundles.

This also means using Hooks from other libraries like React Router and React Redux wherever support is provided. At this point, everybody has moved to hooks or at least support it.

React memo:

When deciding to update DOM, React first renders your component, then compares the result with the previous render result. If the render results are different, React updates the DOM.

Current vs previous render results comparison is fast. But you can speed up the process under some circumstances.

When a component is wrapped in React.memo(), React renders the component and memoizes the result. Before the next render, if the new props are the same, React reuses the memoized result skipping the next render.

export function MemoComp({ prop1, prop2 }) {
return (
<div>
<div>Property 1 : {prop1}</div>
<div>Property 2: {prop2}</div>
</div>
);
}

export const MemoizedComp = React.memo(MemoComp);
Courtesy of Dmitri Pavlutin

Avoiding Reconciliation:

This basically means to avoid unwanted re-renders. Here are a few things you might have to give a thought about.

  1. Usage of React.memo as shown above, dramatically reduces re-renders.
  2. Subscribe to only used sections of the global store. Subscribing to a larger section opens up possibilities of unwanted re-renders caused by other state changes.
  3. Dispatch One Action for One Reducer that is if you are changing the Redux State at any given point maintain 1–1 ratio of action to dispatch. The below example dispatching two actions to the same reducer triggers one extra unwanted Rerender. This is for one simple API Action but as and when the application grows a lot of these things go unnoticed.
const ActionGenerator = () => dispatch => {
dispatch(ON_LOADER);
api().then((data) => {
dispatch(OFF_LOADER)
dispatch(SET_DATA, data)
})
}// This causes 2 updates to store and 2*componenets times Rerenders

What if you have to dispatch multiple actions or you have to update two sub-states. You can use Redux Batch to speed to do this. batch API allows any React updates in an event loop tick to be batched together into a single render pass.

import { batch } from 'react-redux'const myThunk = () => (dispatch, getState) => {
// should only result in one combined re-render, not two
batch(() => {
dispatch(increment())
dispatch(increment())
})
}

Avoiding Usage of Large Libraries:

There are certain libraries like Moment, Loadash, etc., that huge packages without the support of Tree Shaking (Tree shaking is a term commonly used in the JavaScript context for unused code elimination). These libraries increase the time for the initial load. Take a look below.

Moment JS bundle stats

A good alternative to Moment would be date-fns (18kb)or DayJS (2kb) day js has fewer features in comparison with date-fns. For lodash we started writing our own utils inspired by lodash.

We wrote most of our components from scratch without using any UI Framework like antd thus reaping the benefits of performance.

📝On a side note, Ant Design is one of the most feature-packed React Libraries out there. Solves most of your problems with less development time and effort.

Optimizing CSS and Assets

As mentioned in the above point we build our own UI Components likewise we refrained from using popular CSS libraries like Bootstrap, Bulma, etc., this means CSS contains only styles we used and not any other fluff.

Purging CSS: This is a tool that removes unused CSS. This is especially useful if you are using any CSS Library (we did not have to) it just removes all unwanted styles.

SVG Components for all our assets: All the Design assets like Icons, Illustrations, Badges, etc., apart from Few Images were exported as SVG’s and used as React Components show below.

// Component 
import React from 'react';
import SvgWrapper from '.';
const AssetsExample = props => (
<SvgWrapper {...props}>
<path></path>
// other SVG markup
</SvgWrapper>
);
// Usage...
<AssetsExample fill="#555555" width={x} />
...

The benefit of this is that all our assets are treated as JS files. They are compiled and minified along with other JS which makes it much lighter. Elsewise you would have to load SVG’s as Images even via a CDN.

Lighthouse specific enhancements

Lighthouse is an Industry standard for web performance. The criteria for scoring in Performance as of lighthouse 6.0 by order of weightage is Largest Contentful Paint, Total Blocking Time, First Contentful Paint, Speed Index, Time to Interactive and Cumulative Layout Shift.

We targeted to maximize the top four criteria which contribute to 80% of the lighthouse score. As that is where most of our business impact is seen.

Largest Contentful Paint — LCP [25%] LCP essentially means time taken to render out the largest image or text field visible within the viewport.

elements that are considered <img>, <image>, <video>,An element with a background image loaded via the url() and Block elements with text or inline elements.

The size of the element reported for Largest Contentful Paint is typically the size that’s visible to the user within the viewport. If the element extends outside of the viewport, or if any of the element is clipped or has non-visible overflow, those portions do not count toward the element’s size.

For image elements that have been resized from their intrinsic size, the size that gets reported is either the visible size or the intrinsic size, whichever is smaller. For example, images that are shrunk down to a much smaller than their intrinsic size will only report the size they’re displayed at, whereas images that are stretched or expanded to a larger size will only report their intrinsic sizes.

For text elements, only the size of their text nodes is considered (the smallest rectangle that encompasses all text nodes).

For all elements, any margin, padding, or border applied via CSS is not considered.

credits: web.dev by google

What we did was to introduce the use of Skeleton Loader with Shimmer Animation via `background-image: URL(<imageUrl>)` before even making the API call to fetch the data. Once the data is fetched we replace the skeleton loader with the actual UI.

Old vs New app loading

This is a better way of communicating to the user what interface to expect as early as possible in the loading process. As they outline the structure itself, they can be hardcoded to appear while the server is still collecting and delivering all the page-specific content. Additionally, the user is provided with a sense of progression as the blank screen fills in with the skeleton and then the content, instead of just staring at a loading spinner or blank screen until the content is rendered.

Total Blocking Time-TBT [25%]: TBT is the minimum wait time required by the user to use the application as in trigger events, such as mouse clicks, screen taps, or keyboard presses. The sum is calculated by adding the blocking portion of all long tasks between First Contentful Paint and Time to Interactive. Any task that executes for more than 50 ms is a long task. The amount of time after 50 ms is the blocking portion. For example, if Lighthouse detects a 70 ms long task, the blocking portion would be 20 ms.

We did not actually do anything specifically to boost the TBT score. But I shall explain things that might push down TBT scores.

Loading, parsing, or execution js(javascript) impacts the time required for users to start interacting hence reducing bundle sizes by methods mentioned above like Lazy-Loading, Usage of Hooks and Nonusage of huge libraries reduces our bundle size which in turn reduced amount of js that has to be processed.

FCP: The First Contentful Paint (FCP) metric measures the time from when the page starts loading to when any part of the page’s content is rendered on the screen. These contents might be Images, SVG elements, Text elements, and white canvas.

credits: web.dev by google

What we did: In a SPA(Single Page application) you would have a Root div element and your entire app would mount on inside that element. like the snippet below.

<body> <div id="app"></div></body>
...

Without the loader in the html file.

...
<body>
<div id="app"> <div id="pageLoaderRoot" class="loaderRoot">
<img class="spinnerImage" src="/path/to/loaderImg"
alt="Loading..." />
<div class="barSpinner"></div>
</div>
</div></body>
...

With the loader inside the HTML file

Here we have placed a loader in form of an image inside Root Div with this the HTML file will load the loader first and later our JS bundles would override contents inside the div with our Application.

By adding the loader like this as soon as the index.html file is loaded the loader spinner is rendered ASAP. This by principle would boost FCP scores by a good margin.

Speed Index: Speed Index measures how quickly content is visually displayed during page load. Lighthouse basically captures a video of the web page loading and calculates the visual difference between each frame.

What we did:

  1. Font Visibility: The easiest way to avoid showing invisible text while custom fonts load is to temporarily show a system font. By including font-display: swap in your @font-face style, you can avoid FOIT in most modern browsers
  2. Preloading Key Requests: We preloaded all our fonts, CSS, and certain JS files via Webpack and use of preload-webpack-plugin. By doing this all our crucial resources required throughout the app are pre-loaded in a non-blocking manner.
  3. Pre connecting to CDN and other tools: The preconnect directive allows the browser to setup early connections before an HTTP request is actually sent to the server. This includes DNS lookups, TLS negotiations, TCP handshakes. This in turn eliminates roundtrip latency and saves time for resolving used resources.
  4. Purge CSS: PurgeCSS is a tool to remove unused CSS. If you are using any third-party library or using your library for styling changes are that they are huge and you are not really using all the selectors. Purge CSS would scan all your selectors and remove all unused CSS that does not have a selector used.

Epilogue

This blog is our story of how we achieved good web performance for a React JS based Single Page Application using standard SCSS. There are certain applications and processes like usage of CSS in JS or using Server Side Rendered(SSR) application that might differ in certain cases from the above.

The need to build fast applications came in as a necessity but the knowledge was my Life Long Learning from past experiences. Likewise, my work at upGrad is now only about building applications but also to build a platform that empowers a ton of folks and helps build the Careers of Tomorrow. Do visit upGrad.com to check out our programs that are completely online! If you wish to work with our ever-enthusiastic team, check out our careers page. We are always on the lookout for ambitious, talented folks!

If this blog was helpful do clap as much as possible and let us know in the comments how it helped you. If you feel anything amiss and there is more to it, Please drop in your comments we are more than happy to engage with your discussion.

Resources

https://reactjs.org/docs/optimizing-performance.html — React Specific optimizations.

https://ui.dev/why-react-hooks/ — React hooks why ?

https://web.dev/performance-scoring/ — Lighthouse scoring deep dive.

https://dmitripavlutin.com/use-react-memo-wisely/ — React Memo deep dive

https://developers.google.com/web/tools/workbox/guides/configure-workbox — workbox configuration deep dive

https://medium.com/@joecrobak/production-deploy-of-a-single-page-app-using-s3-and-cloudfront-d4aa2d170aa3 — S3 deployment made easy

--

--

Technical Lead👨🏽‍💻. Freelance UX Designer 💎. Product Enthusiast. Also Batman by night😅. https://thearvindnarayan.now.sh/