Optimizing for Modern Web š
Optimization is a continuous process, not a one-time checklist. You just need to figure out why and what to optimize. Therefore, measuring and analyzing play a crucial role in exposing the barriers that affect the performance of your awesome application.
This post is about how we achieved significant performance gains by adapting our applications to the modern Web features.
Since Web is evolving continuously, new opportunities coming up to make our apps more performant and user-friendly. These are the steps of our optimization process: Measure, Analyze, Optimize, Monitor.
Note: You shouldnāt jump over to the optimization step without making an analysis, because it may not be what your application needs to improve and may cause a waste of time.
š Measure & Analyze
Letās start with lighthouse audits because this will give us a baseline on where to look afterward. Look at the below lighthouse result, there is a metric that differs from the others. Speed index is calling us for help.
Speed Index shows how quickly the contents of a page are visibly populated. And optimizing it improves our overall performance score.
JavaScript execution time may be a huge bottleneck to the speed index. More bytes cause more network, parse & compile, execution, and memory costs.
Before making a decision, letās measure a few more metrics.
We can get the network trace of our application by recording a JavaScript profile from DevTools. As the below image shows, there is a long-running task in the time frame which loads the main JavaScript file for the page.
Letās capture the code usage coverage of that file:
It seems like ~53% of the code in this script is never used. By inspecting the contents of the file we can see that the code has ES5 syntax (lots of var
, function
, Object.assign
statements).
We know that ES6 is supported by all modern browsers, so thereās no reason not to use it. Also, there is just one JavaScript file that hydrates all the content of the page. We can use code splitting according to usage and cost.
ā” Optimize
After measuring and making analysis we can tell that minimizing the large files can make difference in terms of network & Javascript execution time. We can ship ES6 code with code splitting and still support the legacy browsers.
We recently switched from webpack to ViteJS because of Viteās out-of-the-box features like auto/manual chunking, ESM support, legacy plugin, HMR, built-in typescript, react, CSS modules support, esbuild, etc.
By default, you donāt need to have a custom ViteJS build config. But we needed to customize it because of some cases, and I think it may be helpful to share that.
You may have some external scripts/modules loaded to your page and you donāt want to include them in your bundle. Therefore, you need to define your externals in the vite.config.ts
as below.
We specify externals as
rollupOptions.external
for the ES modulescommonjsOptions.ignore
for the CJS packagescommonjsOptions.esmExternals
for the ESM & CJS mixed packages
This configuration might be enough for you but, Vite has known issues in some particular cases (like mixed module formats). If you get some errors about the external dependencies, you can talk with the author or if you are the author you can publish the ESM version of that package.
We had a 69.1 kB bundle before the optimization.
Now, we have 16.9 kB main.js and 5.9 kB vendor chunks (auto-generated by Vite).
Letās look at the network trace now. 25.41 s long task is now just 5.43 s long.
Also, the unused code percentage reduced from 52.9% to 18.7%.
And, these changes reflected in our overall performance score.
Note: We didnāt focus on just one metric. We ship less code to the browser so, it speeds up the JavaScript execution time and increases our overall performance score.
Supporting Legacy (EOL) Browsers š«
Serving modern code minimized our bundle size by ~%70. But modern code only works at modern browsers and we still need to support old browsers like IE ā¤ 11, Safari ā¤ 9 which don't support ES modules and are still used by significant numbers of users. (Although ~2% may seem small, if you have millions of users, supporting old browsers can result in significant revenue).
We use differential serving approach to support both modern and legacy browsers. Usingnomodule
module
content attributes enables to load & execute different scripts according to the user agent.
Vite has an official legacy plugin named @vitejs/plugin-legacy for differential serving. But I forked this plugin because of some issues the official plugin has.
Usage of the plugin is pretty simple. You just pass your target like browserlist and specify some polyfill if you need to. Additionally, you have to use regenerator-runtime
to support IE 11.
It creates ES5 output using babel which uses SystemJS for module imports. You can add them tonomodule
scripts.
š Monitor
We shouldnāt leave without monitoring changes. Use tools like lighthouse-ci, lighthouse, web-vitals to gather data and monitor it periodically. We saw significant changes in our charts as below. Also, make sure that your error rate is not affected by these changes.
Further āPossibleā Optimization Ideas:
- Use HTTP/2
- Move expensive tasks to off-main threads with web workers
- Preload / precache critical assets (service workers).
- Lazy Load non-critical assets
- Optimize your images (WebP, WebM)
- Only render visible elements if possible. (content-visibility, react-window)
- Make sure that you are not shipping more than your userās need.
Thank you for reading, bye ā