Hi Folks!!
Hmm, So you have already developed an application & you are looking at how to optimize it?
In this article, We will be taking you through the journey that we followed while working on frontend optimization at LitmusChaos.
Firstly, Why did we have to work on optimization for our frontend?
So, as we all know, first impressions make sure how further things are going to move forward, right? The same thing goes with your Software. And as we are talking about Software, When your client is going to open that great application you made, What if that application takes a very long time to load & they are waiting just to log in. And that even when that application is not even at its peak level or I would say it is not having a huge load on itself.
Well, the same thing happened to us. We have been working on ChaosCenter for quite a long time now and it has grown to a very large extent. Last year, We found that our application was taking around 50 seconds just to load a login page. This motivated us to look into it deeply.
Well, as a developer you always start with Developer-tools, right? We also did the same & started looking at chunks getting transferred while loading a single page. We found that it was transferring around ~5MBs when a user was just trying to load the login page for logging in.
Now, that is a problem, right? Your application shouldn't be taking that much time to load or even that much of a big chunk shouldn't be getting transferred just for a single page.
So, Are you ready for this journey?
Well, with the benchmarking numbers we had, we started digging into the working/bundling process of React JS & different ways of optimizing that. And Oh!, by the way, I forgot to tell you, Our application is built using React. So, many things are already taken care of.
While looking into the bundling process of Webpack, we got to know different styles of importing components (Static/Dynamic). Well, if your application is small, it doesn't matter which style you pick but an application like ours does matter.
This leads us to the concept of Lazy-loading & code-splitting.
Lazy-Loading & Code-splitting
Fortunately, we had written our codebase in such a way that all components are well isolated & implemented. The only problem there was that we were using static imports everywhere, due to which all the components were getting loaded, whichever was imported in a file. And when we started looking into our codebase, we found that we had a central file, which has the routing logic & all the pages/views were getting imported there.
Let's look at the below example snippet from our Router file.
import CreateWorkflow from '../../pages/CreateWorkflow';
import LoginPage from '../../pages/LoginPage';
import GetStarted from '../../pages/GetStartedPage';
import WorkflowDetails from '../../pages/WorkflowDetails'
# Router component
<Router history={history}>
{/* <Routes /> */}
<Routes />
</Router>
So If you see here, the router was working as intended, whenever a user started the app, it was routed to the sign-in page. But if we check the background, it was loading all the pages/views & then redirecting to the sign-in page.
Here, what we wanted was to just load the sign-in page & route to it.
So we started with Router-based code-splitting. We changed all static imports from static to dynamic imports using built-in lazy-loading from Webpack & React.
const CreateWorkflow = lazy(() => import('../../pages/CreateWorkflow'));
const LoginPage = lazy(() => import('../../pages/LoginPage'));
const GetStarted = lazy(() => import('../../pages/GetStartedPage'));
const WorkflowDetails = lazy(() => import('../../pages/WorkflowDetails'));
# Router component
<Suspense fallback={<Loader />} >
<Router history={history}>
{/* <Routes /> */}
<Routes />
</Router>
</Suspense>
With the above changes, On trying to login to the application, Webpack will fetch the Router component chunk & then only the Login-page chunk as only that is required.
Now, at this time, we tried to build our frontend. And believe me, we knew that we had something because we had divided our build chunks from having size 3MBs to 1.5-2MBs.
Moving ahead, We also followed the same approach at the components level & changed all component imports in our pages by analyzing user stories to dynamic ones.
Well, you might be having a question why would a component imported on a page be imported as dynamic?
Let's take an example, you can have a page, where on clicking on a button, you show a modal or in our case a complete Code Editor. Well, a code editor is a big component and users might not even click on that button. So, We changed the imports everywhere to dynamic ones. I think with this you must have got the point here.
The previous exercise had a great impact on our number of chunks (~98 chunks) & their sizes, (Obviously on load time as well) as you can see in the below screenshot.
Sources Tab, Your good friend?
We started digging more into different functionalities of Chrome, lighthouse & other tools. We found that Chrome provides a sources tab in Developer tools. Whenever we open an application or website, Source Tab provides us with all resources imported to that instance for having that software/website/app working as ideal. We saw that when we were just trying to open the Login page, It was importing all components from our component library, even though no other page/screen was getting loaded.
Okay okay, I didn't tell you, we also have our Component library (litmus-ui) built upon Rollup. This is a very well maintained & optimized component library that we use for our different products.
Well, interestingly, if you see in the above image, Our Login page was only using text-boxes & buttons. But, other components including analytics-related graphical components, icons & even lab components were getting loaded in the browser, which was not getting used or imported into the login page.
So, we started looking deeply in our Library, tried making some changes here & there, and tested with a small React App (Well, you don't want to wait for a build just for a single change, right). In all cases, it was importing all components from our library.
After looking into other component libraries, we found one pattern and that was for every component, they were having default exports along with named exports.
This ensured that no redundant code/components were loaded whenever a single component was imported from the library and also helped us in allowing path-based imports from our library as shown below.
# Previously only this was possible
import {ButtonFilled} from "litmus-ui";
# Now, all below given imports are possible
import { ButtonFilled } from "litmus-ui";
import { ButtonFilled } from "litmus-ui/core";
import { ButtonFilled } from "litmus-ui/core/Button";
import { ButtonFilled } from "litmus-ui/core/Button/ButtonFilled";
import ButtonFilled from "litmus-ui/core/Button/ButtonFilled";
What about Tree-shaking at the Components-library level?
Well, with the above analysis we were sure that tree-shaking in our library wasn't working as expected. We started looking into tree-shaking more at the library level.
We stepped inside node_modules, after many hits & trials and comparing other libraries, we found that our library wasn't even supporting tree-shaking. Let's see the reason in the below picture as well as rollup.config.js
that we had earlier -
output: [
{
dir: "dist",
format: "cjs",
sourcemap: true,
},
],
In the above picture, if you see, our library was only bundled to cjs
(commonJS) format, which doesn't support tree-shaking.
Then we started looking into what we can do to achieve it. Well, this was the time when we found that esm
(ES Modules) format is the one that supports this. So we changed our configuration file of the library to create the bundle in esm
format.
output: [
{
dir: "dist",
format: "cjs",
sourcemap: true,
},
{
dir: "dist",
format: "esm",
sourcemap: true,
},
],
Well, in the above configuration, we had made one mistake i.e. we had given the same destination directory as dist
in both outputs, which in return was overriding each other and we were only getting cjs
format.
So we changed the output dir for esm
to dist/esm
, with this in place, we had our esm
bundle generated.
Now, our rollup.config.js was looking like below -
output: [
{
dir: "dist",
format: "cjs",
sourcemap: true,
},
{
dir: "dist/esm",
format: "esm",
sourcemap: true,
},
],
Still, the same result, React wasn't using the esm
module in our application. And we were also having one question in our mind, well we have bundled our library in 2 formats but how are we going to tell React to use esm
format?
After a little research, we found that we have to provide a path for esm
bundle in the module
field in package.json of our component library.
So, we added both paths for cjs
& esm
in package.json in the fields main
& module
respectively.
The above configuration made our library be used easily by both traditional bundlers as well as modern bundlers like webpack.
NOTE: Traditional bundlers who don't understand the esm
type can make use of cjs
with this configuration.
Well, while we were looking into this, we got our eyes on the build output of the library, we saw that it was creating only one chunk because we were giving only one entry-point in config.
We tried with an array of entry points (One root & one for only one of the components). And well, guess what we had achieved what we wanted. We tried importing the same component, and only that component was loaded.
input: ["./src/index.ts","./src/core/Button/ButtonFilled/index.ts" ],
So, now we had all the things with us and we knew what we had to do. Firstly we thought let's just make a rule for every developer to add an entry-point in an array whenever he/she adds a new component. But then we thought this can have issues as we can forget to do so every time, After all, We all are Humans :-).
So, we made a script in JS which would go through all components and extract their relative paths & provide them as an array to the entry point in the config input.
# scripts/inputs.js
const fs = require("fs");
const path = require("path");
const getAllEntryPoints = function (dirPath, arrayOfFiles) {
let files = fs.readdirSync(dirPath);
arrayOfFiles = arrayOfFiles || [];
files.forEach(function (file) {
if (fs.statSync(dirPath + "/" + file).isDirectory()) {
arrayOfFiles = getAllEntryPoints(dirPath + "/" + file, arrayOfFiles);
} else {
if (file === "index.ts") {
arrayOfFiles.push(path.join(dirPath, "/", file));
}
}
});
return arrayOfFiles;
};
export default getAllEntryPoints;
# In rollup.config.js
import getAllEntryPoints from "./scripts/inputs";
const input = getAllEntryPoints("./src");
And with this, we were able to make our library Tree-shakable & still developer-friendly at the same time.
Well, After this whole exercise & brain-storming, what we saw -
And with help of different compression techniques using brotli & gzip, we were able to achieve the below results -
This was a great exercise for us as we got to know more about the bundling process, optimization techniques as well as the working of component libraries.
Well, This is it for now, Thanks for staying with me till here, Will be sharing more..as this is going to be continued, let's see what can be done to go below 2 MBs without compression.
Conclusion
Feel free to check out our ongoing project - Chaos Center and do let us know if you have any suggestions or feedback regarding the same. You can always submit a PR if you find any required changes.
Make sure to reach out to us if you have any feedback or queries. Hope you found the blog informative!
If chaos engineering is something that excites you or if you want to know more about cloud-native chaos engineering, donโt forget to check out our Litmus website, ChaosHub, and the Litmus repo. Do leave a star if you find it insightful. ๐
I would love to invite you to our community to stay connected with us and get your Chaos Engineering doubts cleared.
To join our slack please follow the following steps!
Step 1: Join the Kubernetes slack using the following link: https://slack.k8s.io/
Step 2: Join the #litmus channel on the Kubernetes slack or use this link after joining the Kubernetes slack: https://slack.litmuschaos.io/
Cheers!
litmuschaos / litmus
Litmus helps SREs and developers practice chaos engineering in a Cloud-native way. Chaos experiments are published at the ChaosHub (https://hub.litmuschaos.io). Community notes is at https://hackmd.io/a4Zu_sH4TZGeih-xCimi3Q
Open Source Chaos Engineering Platform
Read this in other languages.
๐ฐ๐ท ๐จ๐ณ ๐ง๐ท ๐ฎ๐ณ
Overview
LitmusChaos is an open source Chaos Engineering platform that enables teams to identify weaknesses & potential outages in infrastructures by inducing chaos tests in a controlled way. Developers & SREs can practice Chaos Engineering with LitmusChaos as it is easy to use, based on modern Chaos Engineering principles & community collaborated. It is 100% open source & a CNCF project.
LitmusChaos takes a cloud-native approach to create, manage and monitor chaos. The platform itself runs as a set of microservices and uses Kubernetes custom resources (CRs) to define the chaos intent, as well as the steady state hypothesis.
At a high-level, Litmus comprises of:
- Chaos Control Plane: A centralized chaos management tool called chaos-center, which helps construct, schedule and visualize Litmus chaos workflows
- Chaos Execution Plane Services: Made up of aโฆ
Top comments (0)