Skip to main content
โšก Calmops

Frontend Performance Optimization: A Practical Guide

Introduction

Frontend performance directly impacts business outcomes: a 100ms improvement in load time can increase conversion rates by 1%. Google’s Core Web Vitals are now ranking signals. This guide covers the techniques that move the needle โ€” measured, not assumed.

Prerequisites: Basic knowledge of JavaScript, HTML, CSS, and a build tool (Vite or webpack).

Core Web Vitals: What to Measure

Google’s Core Web Vitals are the metrics that matter most for user experience and SEO:

Metric What it measures Good Needs Work Poor
LCP (Largest Contentful Paint) Loading performance โ‰ค 2.5s 2.5-4s > 4s
INP (Interaction to Next Paint) Responsiveness โ‰ค 200ms 200-500ms > 500ms
CLS (Cumulative Layout Shift) Visual stability โ‰ค 0.1 0.1-0.25 > 0.25

Measure first, optimize second. Use these tools:

# Lighthouse CLI
npm install -g lighthouse
lighthouse https://yoursite.com --view

# WebPageTest
# https://www.webpagetest.org/

# Chrome DevTools: Performance tab, Lighthouse tab
# PageSpeed Insights: https://pagespeed.web.dev/

Bundle Optimization

Code Splitting

Don’t ship all your JavaScript upfront. Split by route and load on demand:

// React: lazy load routes
import { lazy, Suspense } from 'react';
import { Routes, Route } from 'react-router-dom';

const Dashboard = lazy(() => import('./pages/Dashboard'));
const Settings  = lazy(() => import('./pages/Settings'));
const Admin     = lazy(() => import('./pages/Admin'));

function App() {
    return (
        <Suspense fallback={<div>Loading...</div>}>
            <Routes>
                <Route path="/dashboard" element={<Dashboard />} />
                <Route path="/settings"  element={<Settings />} />
                <Route path="/admin"     element={<Admin />} />
            </Routes>
        </Suspense>
    );
}
// Vite: manual chunk splitting
// vite.config.ts
export default defineConfig({
    build: {
        rollupOptions: {
            output: {
                manualChunks: {
                    // Vendor chunk: rarely changes, cached longer
                    vendor: ['react', 'react-dom', 'react-router-dom'],
                    // Feature chunk: load only when needed
                    charts: ['recharts', 'd3'],
                    editor: ['@monaco-editor/react'],
                },
            },
        },
    },
});

Tree Shaking

Ensure unused code is eliminated:

// package.json โ€” mark package as side-effect free
{
  "sideEffects": false
}
// BAD: imports entire lodash (~70KB)
import _ from 'lodash';
const result = _.groupBy(data, 'category');

// GOOD: imports only what you need (~1KB)
import groupBy from 'lodash/groupBy';
const result = groupBy(data, 'category');

// BETTER: use native alternatives
const result = Object.groupBy(data, item => item.category);  // ES2024

Analyze Your Bundle

# Vite
npm install rollup-plugin-visualizer
# Add to vite.config.ts, run build, opens browser with treemap

# webpack
npm install webpack-bundle-analyzer

Look for:

  • Duplicate dependencies (same library bundled twice)
  • Large libraries with small usage (import only what you need)
  • Development-only code in production bundle

JavaScript Performance

Defer Non-Critical Scripts

<!-- BAD: blocks HTML parsing -->
<script src="analytics.js"></script>

<!-- GOOD: defer โ€” executes after HTML parsed, in order -->
<script src="app.js" defer></script>

<!-- GOOD: async โ€” executes as soon as downloaded, out of order -->
<script src="analytics.js" async></script>

Rule: defer for scripts that need the DOM; async for independent scripts (analytics, ads).

Avoid Long Tasks

Tasks over 50ms block the main thread and cause poor INP:

// BAD: processes 10,000 items synchronously, blocks UI
function processAll(items) {
    return items.map(item => heavyTransform(item));
}

// GOOD: yield to browser between chunks
async function processInChunks(items, chunkSize = 100) {
    const results = [];
    for (let i = 0; i < items.length; i += chunkSize) {
        const chunk = items.slice(i, i + chunkSize);
        results.push(...chunk.map(item => heavyTransform(item)));
        // Yield to browser โ€” allows rendering and input handling
        await new Promise(resolve => setTimeout(resolve, 0));
    }
    return results;
}

// BEST: use Web Workers for CPU-intensive work
const worker = new Worker('/workers/processor.js');
worker.postMessage({ items });
worker.onmessage = (e) => console.log('Done:', e.data);

Debounce and Throttle

// Debounce: wait until user stops typing
function debounce(fn, delay) {
    let timer;
    return (...args) => {
        clearTimeout(timer);
        timer = setTimeout(() => fn(...args), delay);
    };
}

const handleSearch = debounce(async (query) => {
    const results = await searchAPI(query);
    setResults(results);
}, 300);

// Throttle: limit to once per interval
function throttle(fn, interval) {
    let lastCall = 0;
    return (...args) => {
        const now = Date.now();
        if (now - lastCall >= interval) {
            lastCall = now;
            fn(...args);
        }
    };
}

const handleScroll = throttle(() => {
    updateScrollPosition();
}, 100);

Image Optimization

Images are typically the largest assets on a page and the biggest opportunity for LCP improvement.

Modern Formats

<!-- Use WebP/AVIF with fallback -->
<picture>
    <source type="image/avif" srcset="hero.avif">
    <source type="image/webp" srcset="hero.webp">
    <img src="hero.jpg" alt="Hero image" width="1200" height="600">
</picture>

Size savings: AVIF ~50% smaller than JPEG, WebP ~30% smaller.

Responsive Images

<!-- Serve different sizes for different screens -->
<img
    src="image-800.jpg"
    srcset="image-400.jpg 400w,
            image-800.jpg 800w,
            image-1200.jpg 1200w"
    sizes="(max-width: 600px) 400px,
           (max-width: 1200px) 800px,
           1200px"
    alt="Description"
    width="800"
    height="600"
>

Lazy Loading

<!-- Native lazy loading โ€” supported in all modern browsers -->
<img src="below-fold.jpg" loading="lazy" alt="...">

<!-- Eager for above-the-fold images (LCP candidate) -->
<img src="hero.jpg" loading="eager" fetchpriority="high" alt="...">

Next.js Image Component

import Image from 'next/image';

// Automatically: WebP conversion, responsive sizes, lazy loading, blur placeholder
<Image
    src="/hero.jpg"
    alt="Hero"
    width={1200}
    height={600}
    priority={true}  // for LCP image โ€” don't lazy load
    placeholder="blur"
    blurDataURL="data:image/jpeg;base64,..."
/>

CSS Performance

Critical CSS

Inline the CSS needed for above-the-fold content, defer the rest:

<head>
    <!-- Critical CSS inlined -->
    <style>
        /* Only styles needed for initial viewport */
        body { margin: 0; font-family: sans-serif; }
        .hero { height: 100vh; background: #000; }
    </style>

    <!-- Non-critical CSS loaded asynchronously -->
    <link rel="preload" href="styles.css" as="style" onload="this.onload=null;this.rel='stylesheet'">
    <noscript><link rel="stylesheet" href="styles.css"></noscript>
</head>

Avoid Layout Shifts (CLS)

/* BAD: image without dimensions causes layout shift */
img { width: 100%; }

/* GOOD: reserve space with aspect-ratio */
img {
    width: 100%;
    aspect-ratio: 16 / 9;
    object-fit: cover;
}

/* GOOD: explicit width and height (set in HTML too) */
img {
    width: 800px;
    height: 450px;
}
/* BAD: web font causes FOUT (Flash of Unstyled Text) */
@font-face {
    font-family: 'MyFont';
    src: url('font.woff2');
}

/* GOOD: font-display: swap shows fallback immediately */
@font-face {
    font-family: 'MyFont';
    src: url('font.woff2') format('woff2');
    font-display: swap;
}

Caching

HTTP Cache Headers

# nginx: cache static assets for 1 year (content-hashed filenames)
location ~* \.(js|css|png|jpg|svg|woff2)$ {
    expires 1y;
    add_header Cache-Control "public, immutable";
}

# HTML: no cache (always fresh)
location ~* \.html$ {
    add_header Cache-Control "no-cache, must-revalidate";
}

Service Worker Cache

// sw.js: cache-first for static assets
self.addEventListener('fetch', (event) => {
    if (event.request.destination === 'script' ||
        event.request.destination === 'style' ||
        event.request.destination === 'image') {
        event.respondWith(
            caches.match(event.request)
                .then(cached => cached || fetch(event.request))
        );
    }
});

Resource Hints

Tell the browser what to load next:

<!-- Preconnect: establish connection early (DNS + TCP + TLS) -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://api.example.com">

<!-- Preload: fetch critical resources early -->
<link rel="preload" href="/fonts/inter.woff2" as="font" crossorigin>
<link rel="preload" href="/hero.jpg" as="image">

<!-- Prefetch: fetch resources for next navigation -->
<link rel="prefetch" href="/dashboard.js">

<!-- DNS prefetch: resolve DNS early (cheaper than preconnect) -->
<link rel="dns-prefetch" href="https://analytics.example.com">

Performance Budget

Set limits and fail the build if exceeded:

// vite.config.ts
export default defineConfig({
    build: {
        rollupOptions: {
            output: {
                // Warn if any chunk exceeds 500KB
            },
        },
        chunkSizeWarningLimit: 500,  // KB
    },
});
// .lighthouserc.json
{
  "ci": {
    "assert": {
      "assertions": {
        "categories:performance": ["error", {"minScore": 0.9}],
        "first-contentful-paint": ["error", {"maxNumericValue": 2000}],
        "largest-contentful-paint": ["error", {"maxNumericValue": 2500}],
        "cumulative-layout-shift": ["error", {"maxNumericValue": 0.1}]
      }
    }
  }
}

Quick Wins Checklist

  • Enable gzip/brotli compression on the server
  • Add loading="lazy" to below-fold images
  • Add width and height to all images
  • Use defer on non-critical scripts
  • Preconnect to critical third-party origins
  • Convert images to WebP/AVIF
  • Set long cache headers for hashed assets
  • Remove unused CSS (PurgeCSS)
  • Split vendor bundle from app bundle
  • Inline critical CSS

Resources

Comments