Skip to Content
Looking for my portfolio? Click here
Optimizing Front-End Performance

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:

  1. Convert HTML to DOM
  2. Convert CSS to CSSDOM
  3. Combine DOM and CSSDOM to create the Render Tree
  4. From the style information on the Render Tree, calculate the position and size of elements in the Layout step
  5. 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

  1. Optimize Size
  2. Cache
  3. Optimize wait time

Optimize Size

5 ways to optimize the size

  1. Minify
  2. Tree-shaking
  3. Code Split
  4. Compress
  5. Image optimize

Minify

Removes whitespace, strips comments


Tool:

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)); // 6
import _ from "lodash"; _.cloneDeep({}); ❌ Bad import { cloneDeep } from "lodash"; cloneDeep({}); ✅ Good

Size of library

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:

  1. Minify: Removes whitespace, strips comments
  2. Tree-shaking:
    1. Remove unused functions
    2. Remove unused library
  3. Code Split
  4. Compress

Image

  • Reduce resolution of image before upload
    1. 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/

  1. Reduce size of image
  2. 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

  1. Async + Defer
  2. Lazy loading
  3. Optimize long tasks
  4. Web Worker
  5. 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:

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

Optimize long tasks

Move some functions with low priority to Macrotask Queue

https://youtu.be/eiC58R16hb8

  • 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

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

Reference

Last updated on