Web Optimization Techniques You Must Know

Web Optimization Techniques You Must Know

Performance isn't a nice-to-have, it's core to user experience. A slow app will feel clunky, unresponsive, and untrustworthy. Web tooling gives you levers to pull, but knowing where to look and what to measure separates junior developers from senior engineers.

This guide breaks down practical web optimization techniques you should understand, grouped by:

  • Diagnostics
  • React optimization
  • Loading performance
  • Frontend delivery.

LinkIcon1. Devs Tools

Before you optimize anything, measure. Guessing where the slowdown is will waste your time. Use the browser’s and React’s built-in tools to see exactly where your bottlenecks lie.

LinkIconReact DevTools

The React DevTools extension is your first line of defense against unnecessary re-renders.

  • Component Tree Tab

    Lets you explore the full component hierarchy and its current states

    Toggle these settings:

    • Settings → General → Turn on "Highlight updates when components render". This visually highlight components re-rendering multiple times.
    • Settings → Debugging → Turn off “Hide logs during second render”. For components re-rendering because of React Strict.
  • Profiler Tab

    Think of it as the “Performance” tab but for React.

    Press start recording, trigger events like buttons, then click stop. You’ll see a timeline of renders and state changes. The tallest bars represent the most expensive renders.

  • react-scan

    An alternative to the Profiler that provides deeper insight into render performance in React apps. If you don't own the project, use the React Dev Tools Profiler instead.

LinkIconChrome DevTools: Advanced Tricks

  1. Measure Unused JS and CSS

    Open DevTools → Open Command Palette → Show Coverage. The higher the unused percentage, the more dead weight in your bundle. Consider lazy loading or removing unnecessary libraries.

  2. Break on Attribute Modifications

    Useful when working with third-party libraries or frameworks that modify the DOM through side effect (flickering issues, weird UI jumps, etc.) and you have no idea which script is responsible. This shows you which script touched the DOM.

    In DevTools → Elements Tab, right-click a DOM element → Break on → Turn on "Attribute modifications". The breakpoint appears to the left. When the breakpoint triggers, inspector redirects you to the Sources tab where the script was executed.

  3. CSS Overview

    Go to DevTools → command+shift+p "show CSS Overview" → CSS Overview Tab:

    a. Fonts: shows all the fonts used. You may eliminate unsued fonts, reduce amount of font sizes.

    b. Colors: show all color schemes used in the application. You may eliminate unused, unnecessary, or identical colors.

    c. Contrast issues: shows any issues with the contrast in your application.

    d. Unused declarations: CSS properties that you can clean up.

  4. Rendering Behavior

    In Redering Tab, use tools like:

    a. Layer Borders: visualize compositing layers

    b. Emulate Focused Page: useful to keep modals or menus open for inspection. To activate, check "Emulate a focused page", then inspect the element as usual

    c. Scroll performance issues: detect layout thrashing or repaint problems

  5. Screenshot Node & Animation Inspector

    a. Screenshot: right-click DOM element and "capture node screenshot" for taking a screenshot.

    b. Animations Tab helps you analyze or reverse-engineer CSS animations.

LinkIconWeb Performance Metrics

The Web Performance is very important since Google uses it to rank your site. A good metrics website means better ranking. We will learn all the performance metrics, how to improve, what are the standards.

Where the metrics are extracted from?
Performance metrics can be achieved from two sources:

  • Laboratory data: Data extracted with Lighthouse from machines (e.g. yours or simulated ones) that measures performance and extracts information.
  • Field data: Data extracted from real users in your website. With the API Performance Timings, you can extract the information from each user and send it to a server.

LinkIcona. Time to first byte (TTFB)

TTFB measures the time from browser's first request until the first byte received. This metric is extracted from data laboratory and real users.

ℹ️ TTFB is a very important metric as it affects other metrics.
✅ < 600ms

Tips:

  • Use caching aggressively.
  • Place content closer to users with CDNs.
  • Optimize backend logic and database calls.

LinkIconb. First contentful paint (FCP)

FCP indicates the time took the page to display the first text or image.

ℹ️ Important since tells the user when the website is ready to use
✅ < 1.8s

LinkIconc. Largest contetnful paint (LCP)

LCP measures the time took to display the largest resource in the viewport. Usually a hero image, large heading, or banner.

✅ < 2.5s

Tips:

  • optimized images
  • optimize critical CSS
  • lazy-load non-essential scripts
  • minimize render-blocking resources

LinkIcond. Speed index (SI)

SI measures how quickly the visual content rendered in the viewport

ℹ️ To give you an idea, a blank page for 3 seconds that shows all the content at once is not the same as taking a little longer but showing content progressively. The person who visits the page will perceive it differently.
✅ < 3.4s

LinkIcone. First input delay (FID)

FID measures the time between a user’s first interaction (click, tap, etc.) and the browser responding.

Usually JavaScript doing too much work and blocking the main thread.

ℹ️ This is part of the Web Vitals and gathers information on how many clicks you have done.
✅ < 100ms

LinkIconf. Max ponential first input delay (mFID)

mFID measures the max FID taking into account the time took the main thread being blocked.

✅ < 130ms

LinkIcong. Total blocking time (TBT)

TBT sums up all the longest Javascript tasks (>50ms) that blocked the main thread after FCP.

ℹ️ The longest time it blocked the main thread, less ranked the website.
✅ < 200ms

LinkIconh. Time to interactive (TTI)

TTI measures when a page becomes fully interactive. All scripts loaded, main thread ready, user can click freely.

ℹ️ It's not very stable and little by little it has been abandoned in favor of him DTT.
✅ < 3.8s

LinkIconi. Cumulative layout shift (CLS)

CLS measures all layout shifts that happened in the webpage while page was being loaded. Everyone hates when a button jumps as you click it.

Tip:

  • Always defining image dimensions.
  • Reserving space for ads or embeds.
  • Avoiding injected DOM shifts without placeholders.

✅ < 0.1 score

LinkIcon2. React Optimization Techniques

Optimizing React is about reducing unnecessary computation and re-rendering.

LinkIconReact.memo

Wrap pure components in React.memo to skip re-renders when props haven’t changed.

// Only re-render if the `name` prop changes
const MemoizedUserCard = React.memo(UserCard, (prevProps, nextProps) => {
  return prevProps.name === nextProps.name; // return true means skip re-render
});
 
// Usage
export default function App() {
  const [name, setName] = useState("Jose");
  const [age, setAge] = useState(26);
 
  return (
    <div>
	    <!-- it won't re-render if "name" props are same ->
      <MemoizedUserCard name={name} age={age} />
 
      <button onClick={() => setAge((a) => a + 1)}>Increase Age</button>
      <button onClick={() => setName("Carlos")}>Change Name</button>
    </div>
  );
}

LinkIconuseMemo

Cache expensive calculations between renders.

const result = useMemo(() => heavyComputation(data), [data]);

If it’s expensive and deterministic, memoize it.

LinkIconuseTransition

Mark state updates as non-urgent so React can keep the UI responsive.

Example: filtering large lists on every keystroke.

const [isPending, startTransition] = useTransition();
 
const handleChange = (e) => {
  const value = e.target.value;
 
  // takes normal time to update state
  setKeystroke(value); 
 
  // 👇 wrap non-urgent setState
  startTransition(() => {
    // takes considerable time to update state
    setFilteredUsers(users.filter(u => u.includes(value)));
  });
};

React batches all setState, executing them together before the next rendering. Sometimes one setState (like setFilteredUsers()) takes longer than another, causing lag in the UI. Can you do better? You can tell React that some setState calls are non-urgent, stopping their execution if there's an urgent one, using useTransition().

In this example, on every keystroke, the app filters users. setFilteredUsers() and setKeystroke() are batched by React. setFilteredUsers() is expensive. On every keystroke, React has to wait setFilteredUsers() execution to finish before showing the next keystroke in the UI. The UI will feel non-responsive.

To fix it, make setFilteredUsers() be non-urgent so setKeystroke() happens immediately and the UI be responsive. Wrap setFilteredUsers() with startTransition.

LinkIconDebounce

Make API calls to the server when the user finishes typing, not on every input change.

// Define a debounce function
function debounce (func, delay) {
	let timeoutId;
 
	return function() {
		clearTimeout (timeoutId);
		timeoutId = setTimeout(func, delay);
	}
}
 
// Define a function to handle the search operation
function handleSearch() {
	// Perform search operation
}
 
// Apply debounce to the handleSearch function with a delay of 300 milliseconds
const debouncedSearch = debounce(handleSearch, 300);
 
// Use the debounceSearch

Without debouncing, this leads to excessive API calls or unnecessary processing.

LinkIconReact Virtualized

For rendering large lists efficiently, only render what's visible on screen.

import { List } from 'react-virtualized';
 
function VirtualizedList({ items }) {
	return (
		<List
			width={300}
			height={400}
			rowCount={items.length}
			rowHeight={50}
			rowRenderer={({ index, key, style }) =>
				<div key={key} style={style}>
					{items[index]}
				</div>
			)}
		/>
	)
}

This cuts down on DOM elements and memory usage.

LinkIconReact Fragments

Use fragments (<>...</>) instead of unnecessary wrapper <div>s to reduce DOM nodes.

LinkIcon3. Rendering and loading performance

LinkIconDefer, code splitting and lazy loading

Code splitting lets you split your React application into smaller chunks and load them on-demand. The code source isn't shipped to the browser until you request it, making the first browser bundle size smaller and improving performance. The goal: only load what's needed, when it's needed. This action is also called defer.

You can achieve code splitting in React using React.lazy() and Suspense.

LinkIconReact.lazy + Suspense

When to use:
Only for components. You want to render UI that is not needed initially, like modals, dashboards behind login, chart components.

import React, ( Suspense ) from 'react'
 
const LazyComponent = React.lazy(() => import('./LazyComponent'));
 
function App() {
	const [showPreview, setShowPreview] = useState(false);
	return (
		<br>
			<button onClick={() => setShowPreview(true)}>showPreview</button>
 
			{/* mount LazyComponent on-demand */}
			{
				showPreview ? (
					<Suspense fallback={<div>Loading...</div>}>
						<LazyComponent />
					</Suspense>
				)
				: null
			}
		</>
	)
}

LinkIconLazy loading in Next.js: next/dynamic

Next.js’ dynamic() is a wrapper around React.lazy and Suspense with SSR control.

When to use:
Use this to dynamically import heavy dependencies such as charts, maps, or visualization libraries.

import dynamic from 'next/dynamic'
 
const DynamicMAUChart = dynamic(
	() => import("../components/MAUChart").then((mod) => mod.MAUChart),
  {
	  ssr: false,
	  loading: () => <p>Loading...</p>,	  
  }
)
 
export function Chart(props) {
  return <DynamicMAUChart {...props} />
}

LinkIconLazy load on viewport

You can also use the Intersection Observer API with next/dynamic or React.lazy to only load and render code when it enters the user's viewport.

import { useInview } from "react-intersection-observer"
 
const DynamicMap = dynamic(
	() => import("./component/map-example").then( (mod) => mod.MapExample),
	{
		ssr: false,
		laoding: () => <div>Loading...</div>
	}	
)
 
export function DynamicMap() {
	const [inView, viewRef] = useInView({ triggerOnce: true })
	
	return (
		<div className="w-full h-max" ref={viewRef}>
			{inView && <DynamicMap>}
		</div>
	)
}

LinkIconJavaScript native dynamic imports

You don't always need Next.js or React. JavaScript also supports dynamic imports natively. import() is a native JS feature that loads code on demand. Similar to next/dynamic but only for non-UI components.

When to use:
Use import() when you want to dynamically load utilities, heavy logic, or modules. Not UI components. Perfect for PDF generators, analytics libraries, date formatters, editor engines, etc.

// Super heavy computation
export async function generatePDF (data) (
	const pdfDoc = await PDFDocument.create();
	const page = pdfDoc.addPage([600, 400]);
	page.drawText(`Name: ${data.name}`);
	const pdfBytes = await pdfDoc.save();
	
	const blob = new Blob([pdfBytes], { type: "application/pdf" })
	const url = URL.createObjectURL(blob);
	window.open(url);
}
 
export default function App() {
	const handleDonwloadPDF = async () => {
		const data = { name: "John Doe" }
    // 🔍 import on-demand HERE
		const { generatePDF } = await import("./utils/pdf-generator")
		genertePDF(data)			
	}
	return <button onClick={handleDownloadPDF}>Download PDF</button>
}

Explanation: The PDF code is loaded only when user clicks on Button.

LinkIconLazy Loading Images

For images not visible to the user in the first viewport, lazy load (or delay loading) images until the image is visible to the user. Meanwhile, place a placeholder or small-resolution image until the image is finally loaded.

LinkIcon1. Native HTML loading="lazy" attribute

The simplest approach is using the native loading="lazy" attribute on <img> tags. Modern browsers support this natively.

// Native HTML approach
<img 
  src="/path/to/image.jpg" 
  alt="Description"
  loading="lazy"
  width={800}
  height={600}
/>
 
// Next.js Image component with lazy loading
import Image from 'next/image'
 
<Image 
  src="/path/to/image.jpg"
  alt="Description"
  loading="lazy"  // Next.js Image component supports this
  width={800}
  height={600}
/>

Pros: Zero JavaScript overhead, browser handles it automatically
Cons: Limited control over loading behavior, no placeholder support

LinkIcon2. React LazyLoad library

For more granular control, use libraries like react-lazyload to delay loading heavy images until they're visible.

import LazyLoad from 'react-lazyload'
 
function ImageGallery() {
  return (
    <div>
      <LazyLoad height={600} offset={100} once>
        <img src="/path/to/image1.jpg" alt="Image 1" />
      </LazyLoad>
      
      <LazyLoad height={600} offset={100} once>
        <img src="/path/to/image2.jpg" alt="Image 2" />
      </LazyLoad>
    </div>
  )
}

Configuration options:

  • height: Placeholder height (prevents layout shift)
  • offset: Load images when they're X pixels away from viewport
  • once: Load image only once (default: false, will reload when out of viewport)

LinkIcon3. Lazy loading with blur placeholder (Intersection Observer)

The best user experience combines lazy loading with a blur placeholder until the image loads completely.

import { useState, useEffect, useRef } from 'react'
 
function LazyImageWithBlur({ 
  src, 
  blurSrc,  // Low-quality placeholder (base64 or small image)
  alt,
  className 
}) {
  const [isLoaded, setIsLoaded] = useState(false)
  const [isInView, setIsInView] = useState(false)
  const imgRef = useRef(null)
 
  useEffect(() => {
    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) {
          setIsInView(true)
          observer.disconnect()
        }
      },
      { threshold: 0.01 }
    )
 
    if (imgRef.current) {
      observer.observe(imgRef.current)
    }
 
    return () => observer.disconnect()
  }, [])
 
  return (
    <div 
      ref={imgRef}
      className={`relative overflow-hidden ${className}`}
    >
      {/* Blur placeholder */}
      <img
        src={blurSrc}
        alt={alt}
        className={`absolute inset-0 h-full w-full object-cover transition-opacity duration-700 ${
          isLoaded ? 'opacity-0' : 'opacity-100'
        }`}
        style={{ filter: 'blur(20px)', transform: 'scale(1.1)' }}
        aria-hidden="true"
      />
      
      {/* Actual image */}
      {isInView && (
        <img
          src={src}
          alt={alt}
          onLoad={() => setIsLoaded(true)}
          className={`h-full w-full object-cover transition-opacity duration-700 ${
            isLoaded ? 'opacity-100' : 'opacity-0'
          }`}
        />
      )}
    </div>
  )
}
 
// Usage
function Gallery() {
  return (
    <LazyImageWithBlur
      src="/path/to/high-quality-image.jpg"
      blurSrc="/path/to/tiny-blur-image.jpg"  // or base64 placeholder
      alt="Gallery image"
      className="w-full h-96"
    />
  )
}

Next.js optimized version with blur placeholder:

import Image from 'next/image'
import { useState, useEffect, useRef } from 'react'
 
function BlurLazyImage({ src, blurDataURL, alt, width, height }) {
  const [isLoaded, setIsLoaded] = useState(false)
  const [isInView, setIsInView] = useState(false)
  const containerRef = useRef(null)
 
  useEffect(() => {
    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) {
          setIsInView(true)
          observer.disconnect()
        }
      },
      { threshold: 0.01, rootMargin: '50px' }
    )
 
    if (containerRef.current) {
      observer.observe(containerRef.current)
    }
 
    return () => observer.disconnect()
  }, [])
 
  return (
    <div ref={containerRef} className="relative overflow-hidden">
      {/* Blur placeholder */}
      <div
        className={`absolute inset-0 transition-opacity duration-700 ${
          isLoaded ? 'opacity-0' : 'opacity-100'
        }`}
      >
        <Image
          src={blurDataURL}
          alt={alt}
          width={width}
          height={height}
          className="h-full w-full object-cover"
          style={{ filter: 'blur(20px)', transform: 'scale(1.1)' }}
          aria-hidden="true"
        />
      </div>
 
      {/* High-quality image */}
      {isInView && (
        <Image
          src={src}
          alt={alt}
          width={width}
          height={height}
          onLoad={() => setIsLoaded(true)}
          className={`transition-opacity duration-700 ${
            isLoaded ? 'opacity-100' : 'opacity-0'
          }`}
        />
      )}
    </div>
  )
}

You can implement the blur algorithm used in this repo:

LinkIconWeb Workers

JavaScript is single-threaded. For CPU-heavy tasks, offload computation to a Web Worker so it doesn’t block rendering.

Use it for data parsing, image processing, or complex calculations.

LinkIcon4. Server and Network Optimization

LinkIconUse Streaming with React Server Components (RSC)

Stream dynamic or async computation (e.g., data fetching) from the server to the client, using a Suspense boundary.

This allows you to render static HTML first while the dynamic content is streamed to the client.

async function RenderBLogPost() {	
	const posts = await getBlogPosts();
	return (
		<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3 lg:gap-8">
			{posts.map((post) => (
				<BlogCard key={post.id} post={post} />
			))}
		</div>   
}
 
// 🔍 Streaming happens HERE
function BlogPost() {
	return (
    // static html ...
 
		<Suspense fallback={<div>loading...</div>}>
      {/* dynamic html */}
			<RenderBlogPost />
		</Suspense>
 
    // more static html ...
	)
}

LinkIconServer-Side Rendering (SSR)

SSR renders HTML on the server, and the resulting HTML is sent to the client.

Why is better:

  • Better Performance: SSR improves first load time because it sends pre-rendered HTML to the client, reducing the client bundle.
  • SEO Optimization: Search engines can crawl and index the content of SSR pages more easily, as they receive the complete HTML content upfront.
  • Avoid SSR for highly interactive dashboards where client-side hydration can outweigh the benefits.

Use Next.js or Tanstack Start, they have SSR out of the box.

LinkIconContent Delivery Network (CDN)

A CDN brings your assets geographically closer to users:

  • Reduced latency
  • Improved scalability
  • Lower bandwidth on your origin server

Use it for static assets: images, JS, CSS, and fonts.

LinkIcon5. Asset Optimization

Small files mean fast load times and happy users.

  • Images: compress via squoosh.app
  • Videos: compress via ffmpeg.org
  • Fonts: convert .ttf.woff2 with cloudconvert.com
  • Remove dependencies: audit your package.json for large or unused libraries.