webPerformance

Caching

What is caching?

Caching is a technique used to store copies of files or data in a temporary storage location, known as a cache, to reduce the time it takes to access them. The primary goal of caching is to improve performance by minimizing the need to fetch data from the original source repeatedly.

Types of caching

Browser cache

The browser cache stores copies of web pages, images, and other resources locally on the user’s device. When a user revisits a website, the browser can load these resources from the cache instead of fetching them from the server, resulting in faster load times.

Service workers

Service workers are scripts that run in the background and can intercept network requests. They can cache resources and serve them from the cache, even when the user is offline. This can significantly improve performance and provide a better user experience.

HTTP caching

HTTP caching involves using HTTP headers to control how and when resources are cached. Common headers include Cache-Control, Expires, and ETag.

How caching improves performance

Reduced latency

By storing frequently accessed data closer to the user, caching reduces the time it takes to retrieve that data. This results in faster load times and a smoother user experience.

Reduced server load

Caching reduces the number of requests made to the server, which can help decrease server load and improve overall performance.

Offline access

With service workers, cached resources can be served even when the user is offline, providing a seamless experience.

Implementing caching

Browser cache example

<head>
  <link rel="stylesheet" href="styles.css" />
  <script src="app.js"></script>
</head>

Service worker example

self.addEventListener("install", (event) => {
  event.waitUntil(
    caches.open("v1").then((cache) => {
      return cache.addAll(["/index.html", "/styles.css", "/app.js"]);
    })
  );
});
 
self.addEventListener("fetch", (event) => {
  event.respondWith(
    caches.match(event.request).then((response) => {
      return response || fetch(event.request);
    })
  );
});

HTTP caching example

Cache-Control: max-age=3600

Debouncing and throttling

Debouncing

Debouncing is a technique used to ensure that a function is only executed after a certain amount of time has passed since it was last invoked. This is particularly useful in scenarios where you want to limit the number of times a function is called, such as when handling user input events like keypresses or mouse movements.

Example

Imagine you have a search input field and you want to make an API call to fetch search results. Without debouncing, an API call would be made every time the user types a character, which could lead to a large number of unnecessary calls. Debouncing ensures that the API call is only made after the user has stopped typing for a specified amount of time.

function debounce(func, delay) {
  let timeoutId;
  return function (...args) {
    clearTimeout(timeoutId);
    timeoutId = setTimeout(() => func.apply(this, args), delay);
  };
}
 
// Usage
const handleSearch = debounce((query) => {
  // Make API call
  console.log("API call with query:", query);
}, 300);
 
document.getElementById("searchInput").addEventListener("input", (event) => {
  handleSearch(event.target.value);
});

Throttling

Throttling is a technique used to ensure that a function is called at most once in a specified time interval. This is useful in scenarios where you want to limit the number of times a function is called, such as when handling events like window resizing or scrolling.

Example

Imagine you have a function that updates the position of elements on the screen based on the window size. Without throttling, this function could be called many times per second as the user resizes the window, leading to performance issues. Throttling ensures that the function is only called at most once in a specified time interval.

function throttle(func, limit) {
  let inThrottle;
  return function (...args) {
    if (!inThrottle) {
      func.apply(this, args);
      inThrottle = true;
      setTimeout(() => (inThrottle = false), limit);
    }
  };
}
 
// Usage
const handleResize = throttle(() => {
  // Update element positions
  console.log("Window resized");
}, 100);
 
window.addEventListener("resize", handleResize);

Lazy loading

What is lazy loading?

Lazy loading is a design pattern used to defer the initialization of an object until the point at which it is needed. This can be applied to various types of resources such as images, videos, scripts, and even data fetched from APIs.

How does lazy loading work?

Lazy loading works by delaying the loading of resources until they are actually needed. For example, images on a webpage can be lazy-loaded so that they only load when they come into the viewport. This can be achieved using the loading="lazy" attribute in HTML or by using JavaScript libraries.

Benefits of lazy loading

  • Improved performance: By loading only the necessary resources initially, the page load time is reduced, leading to a faster and more responsive user experience.
  • Reduced bandwidth usage: Lazy loading helps in conserving bandwidth by loading resources only when they are needed.
  • Better user experience: Users can start interacting with the content faster as the initial load time is reduced.

Implementing lazy loading

Using the loading attribute in HTML

The simplest way to implement lazy loading for images is by using the loading attribute in HTML.

<img src="image.jpg" loading="lazy" alt="Lazy loaded image" />

Using JavaScript

For more complex scenarios, you can use JavaScript to implement lazy loading. Here is an example using the Intersection Observer API:

Example
document.addEventListener("DOMContentLoaded", function () {
  const lazyImages = document.querySelectorAll("img.lazy");
 
  const imageObserver = new IntersectionObserver((entries, observer) => {
    entries.forEach((entry) => {
      if (entry.isIntersecting) {
        const img = entry.target;
        img.src = img.dataset.src;
        img.classList.remove("lazy");
        observer.unobserve(img);
      }
    });
  });
 
  lazyImages.forEach((image) => {
    imageObserver.observe(image);
  });
});

In this example, images with the class lazy will only load when they come into the viewport.

Optimizing DOM manipulation for better performance

Minimize direct DOM access

Accessing the DOM is relatively slow, so try to minimize the number of times you read from or write to the DOM. Instead, store references to elements in variables and work with those.

const element = document.getElementById("myElement");
element.style.color = "red";
element.style.backgroundColor = "blue";

Batch DOM changes

Instead of making multiple changes to the DOM one at a time, batch them together. This reduces the number of reflows and repaints.

const element = document.getElementById("myElement");
element.style.cssText = "color: red; background-color: blue;";

Use documentFragment

When adding multiple elements to the DOM, use a documentFragment to minimize reflows and repaints.

const fragment = document.createDocumentFragment();
for (let i = 0; i < 100; i++) {
  const newElement = document.createElement("div");
  newElement.textContent = `Item ${i}`;
  fragment.appendChild(newElement);
}
document.getElementById("container").appendChild(fragment);

Leverage virtual DOM libraries

Libraries like React use a virtual DOM to batch updates and minimize direct DOM manipulation, which can significantly improve performance.

import React from "react";
import ReactDOM from "react-dom";
 
const App = () => (
  <div>
    <h1>Hello, world!</h1>
  </div>
);
 
ReactDOM.render(<App />, document.getElementById("root"));

Use requestAnimationFrame for animations

For smoother animations, use requestAnimationFrame to ensure updates are synchronized with the browser’s repaint cycle.

function animate() {
  // Update animation state
  requestAnimationFrame(animate);
}
requestAnimationFrame(animate);

Avoid layout thrashing

Layout thrashing occurs when you read and write to the DOM repeatedly in a way that forces the browser to recalculate styles and layout multiple times. To avoid this, separate read and write operations.

const element = document.getElementById("myElement");
const height = element.clientHeight; // Read
element.style.height = `${height + 10}px`; // Write

Optimizing network requests for better performance

Minimize the number of requests

Reducing the number of network requests can significantly improve performance. Here are some strategies:

  • Combine files: Merge multiple CSS or JavaScript files into a single file.
  • Image sprites: Combine multiple images into a single sprite sheet and use CSS to display the correct part of the image.
  • Inline small assets: Use data URIs to inline small images or fonts directly into your CSS or HTML.

Use caching

Caching can reduce the need to make network requests for resources that have not changed:

  • HTTP caching: Use Cache-Control and ETag headers to control how and when resources are cached by the browser.
  • Service workers: Implement service workers to cache assets and API responses, allowing your app to work offline and load faster on subsequent visits.

Compress data

Reducing the size of the data being transferred can speed up network requests:

  • Gzip/Brotli compression: Enable Gzip or Brotli compression on your server to compress HTML, CSS, and JavaScript files.
  • Minification: Minify CSS, JavaScript, and HTML files to remove unnecessary characters and reduce file size.

Leverage modern web technologies

Modern web technologies can help optimize network performance:

  • HTTP/2: Use HTTP/2 to take advantage of multiplexing, header compression, and server push, which can reduce latency and improve load times.
  • CDNs: Use Content Delivery Networks (CDNs) to serve static assets from locations closer to the user, reducing latency.

Optimize images

Images often account for a large portion of the data transferred:

  • Responsive images: Use the srcset attribute to serve different image sizes based on the user’s device.
  • Image formats: Use modern image formats like WebP or AVIF, which offer better compression than traditional formats like JPEG or PNG.
  • Lazy loading: Implement lazy loading to defer loading images until they are needed.

Reduce payload size

Reducing the amount of data sent in each request can improve performance:

  • API optimization: Optimize API responses to include only the necessary data.
  • GraphQL: Use GraphQL to request only the specific data needed by the client.

Common performance bottlenecks in JavaScript applications

Common performance bottlenecks in JavaScript applications include inefficient DOM manipulation, excessive use of global variables, blocking the main thread with heavy computations, memory leaks, and improper use of asynchronous operations. To mitigate these issues, you can use techniques like debouncing and throttling, optimizing DOM updates, and leveraging web workers for heavy computations.

Inefficient DOM manipulation

Frequent DOM updates

Frequent DOM updates can be costly because the browser has to re-render the page each time the DOM changes. Batch DOM updates together to minimize reflows and repaints.

Example
// Inefficient
for (let i = 0; i < 1000; i++) {
  const div = document.createElement("div");
  div.textContent = i;
  document.body.appendChild(div);
}
 
// Efficient
const fragment = document.createDocumentFragment();
for (let i = 0; i < 1000; i++) {
  const div = document.createElement("div");
  div.textContent = i;
  fragment.appendChild(div);
}
document.body.appendChild(fragment);

Layout thrashing

Layout thrashing occurs when you read and write to the DOM repeatedly, causing multiple reflows and repaints. Minimize layout thrashing by batching reads and writes separately.

Example
// Inefficient
for (let i = 0; i < 1000; i++) {
  const height = element.clientHeight;
  element.style.height = `${height + 10}px`;
}
 
// Efficient
const height = element.clientHeight;
for (let i = 0; i < 1000; i++) {
  element.style.height = `${height + 10}px`;
}

Excessive use of global variables

Global variables can lead to memory leaks and make the code harder to maintain. Use local variables and closures to limit the scope of variables.

// Inefficient
var globalVar = "I am global";
 
// Efficient
function myFunction() {
  let localVar = "I am local";
}

Blocking the main thread

Heavy computations

Heavy computations can block the main thread, making the UI unresponsive. Use web workers to offload heavy computations to a background thread.

// Main thread
const worker = new Worker("worker.js");
worker.postMessage("start");
 
// worker.js
self.onmessage = function (e) {
  if (e.data === "start") {
    // Perform heavy computation
    self.postMessage("done");
  }
};

Synchronous operations

Avoid synchronous operations like alert, prompt, and synchronous XHR requests, as they block the main thread.

// Inefficient
alert("This blocks the main thread");
 
// Efficient
console.log("This does not block the main thread");

Memory leaks

Memory leaks occur when memory that is no longer needed is not released. Common causes include circular references and unremoved event listeners.

Circular references

// Inefficient
function createCircularReference() {
  const obj1 = {};
  const obj2 = {};
  obj1.ref = obj2;
  obj2.ref = obj1;
}
 
// Efficient
function createNonCircularReference() {
  const obj1 = {};
  const obj2 = {};
  obj1.ref = obj2;
}

Unremoved event listeners

// Inefficient
element.addEventListener("click", handleClick);
 
// Efficient
element.removeEventListener("click", handleClick);

Improper use of asynchronous operations

Unoptimized promises

Chain promises properly to avoid blocking the main thread.

// Inefficient
fetch("url")
  .then((response) => response.json())
  .then((data) => {
    // Process data
  });
 
// Efficient
async function fetchData() {
  const response = await fetch("url");
  const data = await response.json();
  // Process data
}
fetchData();

Debouncing and throttling

Use debouncing and throttling to limit the rate of function execution, especially for event handlers.

// Debouncing
function debounce(func, wait) {
  let timeout;
  return function (...args) {
    clearTimeout(timeout);
    timeout = setTimeout(() => func.apply(this, args), wait);
  };
}
 
// Throttling
function throttle(func, limit) {
  let inThrottle;
  return function (...args) {
    if (!inThrottle) {
      func.apply(this, args);
      inThrottle = true;
      setTimeout(() => (inThrottle = false), limit);
    }
  };
}

Techniques for reducing reflows and repaints

Minimize DOM manipulations

Frequent changes to the DOM can cause multiple reflows and repaints. To minimize this:

  • Use documentFragment to batch DOM updates
  • Clone nodes, make changes, and then replace the original node

Batch DOM changes

Grouping multiple DOM changes together can reduce the number of reflows and repaints:

  • Use innerHTML to update multiple elements at once
  • Use requestAnimationFrame to batch updates

Use CSS classes for style changes

Instead of changing styles directly via JavaScript, use CSS classes:

element.classList.add("new-class");

Avoid complex CSS selectors

Complex selectors can slow down the rendering process:

  • Use simple and direct selectors
  • Avoid deep nesting

Use requestAnimationFrame for animations

Using requestAnimationFrame ensures that animations are synchronized with the browser’s repaint cycle:

function animate() {
  // Animation logic
  requestAnimationFrame(animate);
}
requestAnimationFrame(animate);

Use will-change for frequently changing elements

The will-change property can hint to the browser about which elements will change, allowing it to optimize rendering:

.element {
  will-change: transform;
}

Avoid layout thrashing

Reading and writing to the DOM separately can prevent layout thrashing:

const height = element.offsetHeight; // Read
element.style.height = `${height + 10}px`; // Write

Tools to measure and analyze JavaScript performance

Chrome DevTools

Chrome DevTools is a set of web developer tools built directly into the Google Chrome browser. It provides a Performance panel that allows you to record and analyze the runtime performance of your web application.

  • Open Chrome DevTools by pressing F12 or Ctrl+Shift+I
  • Navigate to the Performance panel
  • Click the “Record” button to start profiling
  • Interact with your web application to capture performance data
  • Click the “Stop” button to end profiling and analyze the results

Lighthouse

Lighthouse is an open-source, automated tool for improving the quality of web pages. It provides audits for performance, accessibility, progressive web apps, SEO, and more.

  • Open Chrome DevTools
  • Navigate to the Lighthouse panel
  • Select the categories you want to audit (e.g., Performance)
  • Click the “Generate report” button
  • Review the generated report for performance insights and recommendations

WebPageTest

WebPageTest is a free online tool that provides detailed performance testing of web pages. It allows you to run tests from multiple locations around the world using real browsers.

  • Go to WebPageTest
  • Enter the URL of the web page you want to test
  • Select the test location and browser
  • Click the “Start Test” button
  • Review the detailed performance report, including metrics like load time, time to first byte, and more

JSPerf

JSPerf is a tool for comparing the performance of different JavaScript snippets. It allows you to create and run benchmarks to see which code performs better.

  • Go to JSPerf
  • Create a new test case by entering different JavaScript snippets
  • Run the benchmark to compare the performance of the snippets
  • Review the results to see which snippet performs better