Optimizing Front-End Performance
CRP (Critical Rendering Path)
What is Critical Rendering Path, Why we need to understand about CRP
Understand the sequence of steps in the CRP will help you understand the nature, the way browser renders a web page. From understanding the nature, you will easily optimize Frontend performance more effectively.
Concept
Alias: Browser Rendering Pipeline
This is the sequence of steps that a browser takes to convert Resources (HTML, CSS, JS) into Pixels displayed on the screen.
Overview
Here is the overview of the browser’s rendering process:
- Convert HTML to DOM
- Convert CSS to CSSDOM
- Combine DOM and CSSDOM to create the Render Tree
- From the style information on the Render Tree, calculate the position and size of elements in the Layout step
- From the information on the Render Tree and Layout, draw the elements onto the screen in the Paint step
Details
Before the browser renders a web page, the DOM and CSSDOM need to be constructed.
1. DOM
This is the process of converting HTML into DOM.
Output: A tree of Nodes containing attribute information (class, id,…) and relationships with other Nodes on the DOM
Example: From the HTML code below, the browsers builds the corresponding DOM
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width,initial-scale=1" />
<link href="style.css" rel="stylesheet" />
<title>Critical Path</title>
</head>
<body>
<p>Hello <span>web performance</span> students!</p>
<div><img src="awesome-photo.jpg" /></div>
</body>
</html>
If you run it onto the Performance tab, the parse HTML task will correspond to this DOM construction step.
2. CSSDOM
This is the step of converting CSS styles into CSSDOM.
Output: Style rules.
Note:
- This is a step independent of the DOM construction step, not yet the final style determination applied to the DOM
- CSS here can be the browser’s default styles (e.g, h1, h2, b, strong, I tags, etc.) and self-defined CSS in
<style>or CSS files.
Example: From the CSS code, the browser will create the CSSDOM
body {
font-size: 16px;
}
p {
font-weight: bold;
}
span {
color: red;
}
p span {
display: none;
}
img {
float: right;
}
If you run it on the Performance tab, the Parse Stylesheet will correspond to this CSSDOM construction step.
3. Render Tree
This is the process of combining the DOM and CSSDOm to create the Render Tree.
Output: A tree of rendered Nodes and the Style rules calculated for those Nodes.
The tree does not contain default hidden Nodes (e.g., HTML, head, script, header, style, meta tags, etc.) and Nodes hidden by CSS (e.g., display: none).
Note: Render Tree also includes all visible pseudo-elements (e.g., before, after) even though these elements may not be present in the DOM.
In general, the Render Tree contains the Nodes that the user can see on the screen.
Example: From the DOM and CSSDOM in steps 1 and 2 above, you will have the Render Tree
If you run the Performance tab, the Recalculate Style will correspond to this Render Tree construction step.
4. Layout
This is the process of calculating the size, position of elements on the browser. Layout is also know as Reflow
Output: Box Model
Example: You declare styles of width, height, … in %, em, rem,… (e.g, width: 50%). The browser will have calculate the size by converting it to pixel and determining the position of the element on the screen.
If you run the Performance tab, the Layout task will correspond to this step.
5. Paint
From the Style information on the Render Tree, the size, position information from Layout, the browser draws the pixels onto the screen, which is the interface that user sees.
If you run the Performance tab, the Paint task will correspond to this step
6. Composite
In reality, in addition to the 5 main steps, there will be an additional process called Composite. This process is responsible for determining the number of layers and combining all the layers to display them on the screen.
The concept of Layer is very simple. A layer refers to separate elements that can be stacked on top of each other
Here’s an example to help you visualize it. You’ve probable assigned position: absolute for two div tags, and use z-index to make one tag overlap the other. That’s exactly how you create two separate layers that can be stacked on top of each other.
If you run the Performance tab, the Layerize task will correspond to this step.
Reference: https://web.dev/articles/critical-rendering-path
Optimize Frontend
After you understand about the CRP, we can define which steps that we can optimize
So we have 3 approaches for optimizing Frontend
- Optimize Size
- Cache
- Optimize wait time
Optimize Size
5 ways to optimize the size
- Minify
- Tree-shaking
- Code Split
- Compress
- Image optimize
Minify
Removes whitespace, strips comments
Tool:
- cssnano
- Vite config minify: https://vite.dev/config/build-options#build-minify
Tree-shaking
Import absolute, import only what we use, not import all
Remove unused functions.
// utils.js
export function calculateSum(a, b) {
return a + b;
}
export function calculateDifference(a, b) {
return a - b;
}
export function calculateProduct(a, b) {
return a * b;
}
// ❌ Bad way - Import all functions even we don't need
import * as utils from "./utils";
console.log(utils.calculateSum(5, 3)); // 8
console.log(utils.calculateDifference(10, 4)); // 6
// ✅ Good way - Import only what we use
import { calculateSum, calculateDifference } from "./utils";
console.log(calculateSum(5, 3)); // 8
console.log(calculateDifference(10, 4)); // 6import _ from "lodash";
_.cloneDeep({}); ❌ Bad
import { cloneDeep } from "lodash";
cloneDeep({}); ✅ GoodSize of library
Bundle Analyzer
- Webpack: https://www.npmjs.com/package/webpack-bundle-analyzer
- Vite: https://www.npmjs.com/package/vite-bundle-analyzer
Optimize the library, and remove unused functions in the library.
Example:
In the analyzer above, moment.js it has a lot of js files for locale. But your project is only use english, so that we can remove the part of locale.
with Webpack:
// webpack new webpack.IgnorePlugin(/./locale$/, /moment$/);// vite + rollup
// vite.config.js
import { defineConfig } from "vite";
export default defineConfig({
build: {
rollupOptions: {
// Example of excluding an external module
external: ["some-external-library"],
},
},
});
Check size of library
Code-split
Split 1 chunk into multiple files chunk
Example with Vite
export default defineConfig((_env: ConfigEnv) => {
return {
build: {
rollupOptions: {
output: {
manualChunks: (id: any) => {
const match = chunkPatterns.find((pattern) => id.includes(pattern));
return match && match;
},
},
},
},
};
});
const chunkPatterns: string[] = [
"@mui",
"classnames",
"lodash",
"@tanstack",
"react-icons/io5",
"react-icons",
"dayjs",
"i18next",
"@emotion",
"react-dom",
"react",
];
Lazy loading Router
Lazy loading Component
Compress
gzip, br
Combine 2 ways minify + compress:
4 steps for Optimize Size:
- Minify: Removes whitespace, strips comments
- Tree-shaking:
- Remove unused functions
- Remove unused library
- Code Split
- Compress
Image
- Reduce resolution of image before upload
- Most of the user monitor is use FullHD (1920x1080px) so we may not need to upload the image with a 4k resolution
- Compress image size before upload to server: https://www.npmjs.com/package/compressorjs
import Compressor from "compressorjs";
export const compressImage = (
file: File,
options: Pick<Compressor.Options, "quality" | "maxHeight" | "maxWidth"> = {
quality: 0.7,
maxWidth: 1920,
maxHeight: 1080,
}
): Promise<File> =>
new Promise((resolve, reject) => {
const isImage = [
"image/jpg",
"image/jpeg",
"image/png",
"image/webp",
].includes(file?.type);
if (isImage) {
new Compressor(file, {
...options,
convertSize: 0,
convertTypes: ["image/jpg", "image/jpeg"],
success(result: File) {
resolve(result);
},
error(err: Error) {
reject(err);
},
});
} else {
resolve(file);
}
});Tool: https://squoosh.app/
- Reduce size of image
- Convert to webp
Always set width and height in img to avoid CLS
Sizes + srcset
<img
src="placeholder-small.jpg"
srcset="
placeholder-small.jpg 480w,
placeholder-medium.jpg 768w,
placeholder-large.jpg 1200w"
sizes="
(max-width: 480px) 100vw,
(max-width: 768px) 100vw,
1200px"
/>Tool: https://www.responsivebreakpoints.com/
Cache
CDN
Example use AWS CloudFront for speeds up distribution of your static and dynamic web content, such as HTML, CSS, JS
https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/ConfiguringCaching.html
Cloudflare
Service-worker
Use workbox-cli to generate service-worker
// workbox.config.cjs
module.exports = {
globDirectory: "dist/admin",
globPatterns: [
"**/*.{ico,html,png,jpg,jpeg,webp,svg,json,txt,css,js,woff,ttf}",
],
globIgnores: ["index.html", "**/index.html"],
maximumFileSizeToCacheInBytes: 15 * 1000 * 1000, // 15MB
swDest: "dist/admin/service-worker.js",
skipWaiting: true,
clientsClaim: true,
cleanupOutdatedCaches: true,
};
IndexDB
Cache the column name of Table into IndexDB
Optimize Wait time
We have 5 ways to optimize
- Async + Defer
- Lazy loading
- Optimize long tasks
- Web Worker
- Preload + Prefetch
Async + Defer
Example of <script async>
Use for script of Google Analytics, monitor the user behavior when click on any element in website, or captures how people use the website, so we need to execute the script ASAP
Example of <script defer>
Show popup for ads after 5-10s after user view the website
Lazy Loading
Image
<img src="placeholder.jpg" loading="lazy" width="400" height="300" />Note:
- Avoid lazy-loading images that appear immediately in the viewport upon page load, as it can affect the LCP (Largest Contentful Paint )
- Keyword: Above the fold
IntersectionObserver
- Call API - on demand
const intersectionObserver = new IntersectionObserver((entries, observer) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
callAPI(); // call API when element in viewport
observer.unobserve(entry.target); //unsubscribe
}
});
});
let targetElement = document.getElementById("target-element");
intersectionObserver.observe(targetElement);Example:
- LazyCheckpoint - https://storybook.vizplatform.nettricity.cloud/react-ui/?path=/story/utils-lazycheckpoint—basic
Virtual Scroll + Infinite Scroll
Virtual Scroll
- Only display the elements that are actually necessary on the screen, while the remaining elements are kept in memory. When the user scrolls, the new elements are calculated and dynamically displayed
- Example: Get 100 records from API, but only show first 20 records in current viewport, when user scroll, calculate the position of user, and render which records in viewport
- https://www.npmjs.com/package/react-window
- https://www.npmjs.com/package/react-virtuoso
Infinite Scroll
- Fetch new records when user scroll into last record.
- Example: Fetch first 20 records, when user scroll into record 20, fetch next record from 20-40
- https://storybook.vizplatform.nettricity.cloud/react-ui/?path=/story/inputs-select—lazy-load
Optimize long tasks
Move some functions with low priority to Macrotask Queue
- Long Task
function longTask() {
for (let i = 0; i < 1000000000; i++) {
// do something
}
console.log("Long task completed!");
}
- Break Long Task
function breakLongTask() {
const totalIterations = 100000000;
const size = 10;
const chunkSize = Math.ceil(totalIterations / size);
for (let i = 0; i < size; i++) {
const start = i * chunkSize;
const end = Math.min((i + 1) * chunkSize, totalIterations);
setTimeout(() => {
for (let j = start; j < end; j++) {
// ... heavy computation
}
console.log(`${i + 1}/${size} completed!`);
}, 0);
}
}
- Example
function saveSettings(){
// Do critical work that is user-visible:
validateForm();
showSpinner();
updateUI();
// Defer work that isn't user-visible to a separate task:
setTimeout(() => {
saveToServer();
sendAnalytics();
hideSpinner();
}, 0);
}
function yieldToMain(){
return new Promise((resolve) => {
setTimeout(() => {
resolve();
}, 0);
})
}
async function saveSettings(){
// Create an array of functions to run:
const tasks = [
validateForm,
showSpinner,
updateUI,
saveToServer,
sendAnalytics,
hideSpinner
];
while(tasks.length > 0){
const task = tasks.shift();
task();
await yieldToMain();
}
}
Preload + Prefetch
Preload
Priority: High
Preload the necessary resources for the current page, helping to reduce the loading time of critical resources.
Example: Preload the background-image in CSS file before browser parse stylesheet
//style.css
.banner {
background-image: url('your-image.jpg')
}
//index.html
<link ref="preload" href="your-image.jpg" as="image"></link>Prefetch
Priority: Low
Predownload to cache the resources that can be used in future
Example: emoji in chat
Fetchpriority
<!-- We don't want a high priority for this above-the-fold image -->
<img src="/images/in_viewport_but_not_important.svg" fetchpriority="low" />
<!-- We want to initiate an early fetch for a resource, but also deprioritize it -->
<link rel="preload" href="/js/script.js" as="script" fetchpriority="low"></link>
<script>
fetch("https://example.com/", {
priority: "low"
}).then(() => {
// Trigger a low priority fetch
});
</script>
HTML
Reduce the number of DOM elements, limit excessive DOM nesting
- Reduce size of HTML
- Easy to manage code
CSS
Inline critical CSS
- Browser doesn’t need to download link css
Avoid CSS @import declarations
- Causes render blocking, due to waiting for to download file to complete
- If you import many, files will be downloaded sequentially one by one.
Font: font-display: swap & font-display: block
JS
Event delegation
// HTML Structure
<ul id="myList">
<li>Item 1</li>
<li>Item 2</li>
<li>Item 3</li>
</ul>;
// ❌ Bad way
let listItems = document.querySelectorAll("#myList li");
listItems.forEach((item) => {
item.addEventListener("click", function () {
console.log("Clicked on: ", this.textContent);
});
});
// ✅ Good way
let list = document.getElementById("myList");
list.addEventListener("click", function (event) {
if (event.target.tagName === "LI") {
console.log("Clicked on: ", event.target.textContent);
}
});DocumentFragment
// ❌ Bad way
// Insert elements to DOM one by one, browser will reflow and re-render after each insertion
const container = document.getElementById("container");
for (let i = 0; i < 1000; i++) {
const item = document.createElement("div");
item.textContent = `Item ${i}`;
container.appendChild(item);
}
// ✅ Good way
// Insert the fragment when ready, browser will only reflow and re-render one.
const fragment = document.createDocumentFragment();
for (let i = 0; i < 1000; i++) {
const item = document.createElement("div");
item.textContent = `Item ${i}`;
fragment.appendChild(item);
}
const container = document.getElementById("container");
container.appendChil(fragment);Debounce + Throttle
- Debounce: https://developer.mozilla.org/en-US/docs/Glossary/Debounce
- Throttle: https://developer.mozilla.org/en-US/docs/Glossary/Throttle
- Rate Limit: https://developer.mozilla.org/en-US/docs/Glossary/Rate_limit
Memorization
Layout Thrashing
Web Worker
Tools used to optimize
Check Performance
- Performance tab
Check Web Vitals + recommend
- Lighthouse tab
Check % code used in 1 file
- Coverage tab
Block request to check if it’s a critical resource
- Network request blocking tab
Check the impact on element rendering when handling events
- Rendering tab
Check layer
- Layer tab
Check CSS performance
Check npm
Can I use