Skip to content
LIAM RECKZIEGELEngineer / builder / curious person
Blog Post

Frontend Performance Optimization: React & Next.js

Comprehensive guide to optimizing React and Next.js applications. Code splitting, lazy loading, image optimization, and Core Web Vitals improvements.

Author: Liam ReckziegelBlog Post · deep dive

Frontend Performance Optimization: React & Next.js

December 9, 2024 (2y ago)

Performance Metrics That Matter

Core Web Vitals directly impact SEO rankings and user experience. Google uses these metrics to rank search results. Poor performance = lower rankings = fewer users.

Core Web Vitals

<2.5s
LCP Target
<100ms
FID Target
<0.1
CLS Target
53%
Users Lost at 3s

LCP (Largest Contentful Paint): < 2.5s

  • Measures loading performance
  • Largest element in viewport

FID (First Input Delay): < 100ms

  • Measures interactivity
  • Time from first interaction to browser response

CLS (Cumulative Layout Shift): < 0.1

  • Measures visual stability
  • Unexpected layout shifts

Additional Metrics:

  • TTI (Time to Interactive): < 3.5s
  • TBT (Total Blocking Time): < 200ms
  • FCP (First Contentful Paint): < 1.8s

Measuring Performance

Chrome DevTools

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

// Programmatic Lighthouse
const lighthouse = require('lighthouse');
const chromeLauncher = require('chrome-launcher');

const chrome = await chromeLauncher.launch({chromeFlags: ['--headless']});
const options = {logLevel: 'info', output: 'html', port: chrome.port};
const runnerResult = await lighthouse('https://yoursite.com', options);

Web Vitals Library

// app/layout.tsx (Next.js)
'use client';

import { useReportWebVitals } from 'next/web-vitals';

export function WebVitalsReporter() {
  useReportWebVitals((metric) => {
    console.log(metric);
    
    // Send to analytics
    if (metric.label === 'web-vital') {
      gtag('event', metric.name, {
        value: Math.round(metric.value),
        metric_id: metric.id,
        metric_label: metric.label,
      });
    }
  });

  return null;
}

Real User Monitoring

// lib/analytics.ts
import { getCLS, getFID, getFCP, getLCP, getTTFB } from 'web-vitals';

function sendToAnalytics(metric: Metric) {
  const body = JSON.stringify(metric);
  
  // Use `navigator.sendBeacon()` if available, falling back to `fetch()`
  if (navigator.sendBeacon) {
    navigator.sendBeacon('/api/analytics', body);
  } else {
    fetch('/api/analytics', { body, method: 'POST', keepalive: true });
  }
}

getCLS(sendToAnalytics);
getFID(sendToAnalytics);
getFCP(sendToAnalytics);
getLCP(sendToAnalytics);
getTTFB(sendToAnalytics);

Code Splitting & Lazy Loading

All Code Upfront

Load everything immediately:

  • 3.2MB initial bundle
  • 15 second load on 3G
  • Users wait for unused code
  • Poor TTI and FCP
Code Splitting

Load only what's needed:

  • 180KB initial bundle
  • 1.8 second load on 3G
  • Components load on demand
  • Excellent Core Web Vitals

React.lazy() for Components

// ❌ Bad: All components loaded upfront
import HeavyChart from './HeavyChart';
import VideoPlayer from './VideoPlayer';
import ImageGallery from './ImageGallery';

// ✅ Good: Lazy load heavy components
const HeavyChart = lazy(() => import('./HeavyChart'));
const VideoPlayer = lazy(() => import('./VideoPlayer'));
const ImageGallery = lazy(() => import('./ImageGallery'));

function Dashboard() {
  return (
    <Suspense fallback={<Skeleton />}>
      <HeavyChart data={data} />
    </Suspense>
  );
}

Next.js Dynamic Imports

// app/page.tsx
import dynamic from 'next/dynamic';

// Client component with no SSR
const DynamicMap = dynamic(() => import('@/components/Map'), {
  ssr: false,
  loading: () => <MapSkeleton />,
});

// With named export
const DynamicChart = dynamic(
  () => import('@/components/Charts').then((mod) => mod.LineChart),
  {
    loading: () => <ChartSkeleton />,
  }
);

export default function Page() {
  return (
    <div>
      <DynamicMap coordinates={coords} />
      <DynamicChart data={data} />
    </div>
  );
}

Route-Based Code Splitting

Next.js does this automatically, but for React Router:

// Router.tsx
import { lazy, Suspense } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';

const Home = lazy(() => import('./pages/Home'));
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Profile = lazy(() => import('./pages/Profile'));

export function Router() {
  return (
    <BrowserRouter>
      <Suspense fallback={<PageLoader />}>
        <Routes>
          <Route path="/" element={<Home />} />
          <Route path="/dashboard" element={<Dashboard />} />
          <Route path="/profile" element={<Profile />} />
        </Routes>
      </Suspense>
    </BrowserRouter>
  );
}

Image Optimization

Next.js Image Component

import Image from 'next/image';

// ✅ Optimized with automatic sizing
<Image
  src="/hero.jpg"
  alt="Hero image"
  width={1200}
  height={600}
  priority // Load immediately (above fold)
  placeholder="blur"
  blurDataURL="data:image/jpeg;base64,..."
/>

// Responsive images
<Image
  src="/product.jpg"
  alt="Product"
  fill
  sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
  className="object-cover"
/>

Generate Blur Placeholders

// lib/getBlurDataURL.ts
import { getPlaiceholder } from 'plaiceholder';

export async function getBlurDataURL(src: string) {
  const buffer = await fetch(src).then(async (res) =>
    Buffer.from(await res.arrayBuffer())
  );

  const { base64 } = await getPlaiceholder(buffer);
  return base64;
}

// Usage in page
export async function generateMetadata({ params }) {
  const blurDataURL = await getBlurDataURL('/hero.jpg');
  
  return {
    // metadata
  };
}

Responsive Images with srcset

// For non-Next.js projects
<img
  src="/images/product-800w.jpg"
  srcSet="
    /images/product-400w.jpg 400w,
    /images/product-800w.jpg 800w,
    /images/product-1200w.jpg 1200w
  "
  sizes="(max-width: 600px) 400px, (max-width: 1200px) 800px, 1200px"
  alt="Product"
  loading="lazy"
  decoding="async"
/>

WebP & AVIF Formats

// next.config.mjs
/** @type {import('next').NextConfig} */
const nextConfig = {
  images: {
    formats: ['image/avif', 'image/webp'],
    deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
    imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
  },
};

export default nextConfig;

Bundle Size Optimization

Analyze Bundle

# Next.js
npm install @next/bundle-analyzer
// next.config.mjs
import bundleAnalyzer from '@next/bundle-analyzer';

const withBundleAnalyzer = bundleAnalyzer({
  enabled: process.env.ANALYZE === 'true',
});

export default withBundleAnalyzer({
  // your config
});
ANALYZE=true npm run build

Tree Shaking

// ❌ Bad: Imports entire library
import _ from 'lodash';
import moment from 'moment';

// ✅ Good: Import only what you need
import debounce from 'lodash/debounce';
import dayjs from 'dayjs'; // Lighter alternative to moment

Remove Unused Dependencies

npm install -g depcheck
depcheck

# Remove unused packages
npm uninstall package-name

Use Lighter Alternatives

// Heavy → Light
moment → dayjs (96% smaller)
lodash → lodash-es (tree-shakeable)
axios → fetch (native)
react-icons → lucide-react (smaller)
chart.js → recharts (React-optimized)

React Performance Optimization

React.memo for Component Memoization

// ❌ Bad: Re-renders every time parent renders
function UserCard({ user }: { user: User }) {
  return <div>{user.name}</div>;
}

// ✅ Good: Only re-renders if user changes
const UserCard = React.memo(
  function UserCard({ user }: { user: User }) {
    return <div>{user.name}</div>;
  },
  (prevProps, nextProps) => prevProps.user.id === nextProps.user.id
);

useMemo for Expensive Calculations

function ProductList({ products, filters }: Props) {
  // ❌ Bad: Filters on every render
  const filteredProducts = products.filter(p => 
    p.category === filters.category
  );

  // ✅ Good: Only recalculates when dependencies change
  const filteredProducts = useMemo(
    () => products.filter(p => p.category === filters.category),
    [products, filters.category]
  );

  return <>{/* render */}</>;
}

useCallback for Function Memoization

function TodoList({ todos }: Props) {
  // ❌ Bad: New function on every render
  const handleToggle = (id: string) => {
    toggleTodo(id);
  };

  // ✅ Good: Function only recreated when deps change
  const handleToggle = useCallback(
    (id: string) => {
      toggleTodo(id);
    },
    [toggleTodo]
  );

  return (
    <>
      {todos.map(todo => (
        <TodoItem key={todo.id} todo={todo} onToggle={handleToggle} />
      ))}
    </>
  );
}

Virtualize Long Lists

// npm install react-window
import { FixedSizeList } from 'react-window';

function VirtualizedList({ items }: { items: Item[] }) {
  const Row = ({ index, style }: { index: number; style: React.CSSStyle }) => (
    <div style={style}>
      <Item data={items[index]} />
    </div>
  );

  return (
    <FixedSizeList
      height={600}
      itemCount={items.length}
      itemSize={80}
      width="100%"
    >
      {Row}
    </FixedSizeList>
  );
}

Avoid Inline Objects & Functions

// ❌ Bad: New object on every render causes re-render
<Component style={{ padding: 20 }} />

// ✅ Good: Define outside or use CSS
const styles = { padding: 20 };
<Component style={styles} />

// ❌ Bad: New function on every render
<Button onClick={()=> handleClick(id)} />

// ✅ Good: Use useCallback or pass stable reference
const handleButtonClick = useCallback(() => handleClick(id), [id]);
<Button onClick={handleButtonClick} />

Next.js Specific Optimizations

Server Components by Default

// app/page.tsx (Server Component - default)
async function Page() {
  // Data fetching on server
  const data = await fetchData();
  
  return <div>{data}</div>;
}

// components/InteractiveButton.tsx (Client Component when needed)
'use client';

export function InteractiveButton() {
  const [count, setCount] = useState(0);
  return <button onClick={()=> setCount(count + 1)}>{count}</button>;
}

Streaming with Suspense

// app/dashboard/page.tsx
import { Suspense } from 'react';

async function SlowComponent() {
  const data = await fetchSlowData(); // Takes 3 seconds
  return <div>{data}</div>;
}

export default function Dashboard() {
  return (
    <div>
      <h1>Dashboard</h1>
      {/* Page loads immediately, this component streams in */}
      <Suspense fallback={<Skeleton />}>
        <SlowComponent />
      </Suspense>
    </div>
  );
}

Static vs Dynamic Rendering

// Static (default) - Built at build time
export default async function Page() {
  const data = await fetch('https://api.example.com/data', {
    next: { revalidate: 3600 } // Revalidate every hour
  });
  return <div>{data}</div>;
}

// Dynamic - Rendered on each request
export const dynamic = 'force-dynamic';

export default async function Page() {
  const data = await fetchUserSpecificData();
  return <div>{data}</div>;
}

// Partial Prerendering (Next.js 14+)
export const experimental_ppr = true;

Font Optimization

// app/layout.tsx
import { Inter, Roboto_Mono } from 'next/font/google';

const inter = Inter({
  subsets: ['latin'],
  display: 'swap',
  variable: '--font-inter',
});

const robotoMono = Roboto_Mono({
  subsets: ['latin'],
  display: 'swap',
  variable: '--font-roboto-mono',
});

export default function RootLayout({ children }: Props) {
  return (
    <html lang="en" className={`${inter.variable} ${robotoMono.variable}`}>
      <body>{children}</body>
    </html>
  );
}

Network Optimization

Prefetching & Preloading

// Next.js automatic prefetching with Link
import Link from 'next/link';

<Link href="/dashboard" prefetch={true}>
  Dashboard
</Link>

// Manual prefetching
import { useRouter } from 'next/navigation';

const router = useRouter();
router.prefetch('/dashboard');

Resource Hints

// app/layout.tsx
export default function RootLayout({ children }: Props) {
  return (
    <html>
      <head>
        {/* Preconnect to external domains */}
        <link rel="preconnect" href="https://fonts.googleapis.com" />
        <link rel="preconnect" href="https://api.example.com" />
        
        {/* DNS prefetch for older browsers */}
        <link rel="dns-prefetch" href="https://analytics.example.com" />
        
        {/* Preload critical resources */}
        <link
          rel="preload"
          href="/fonts/inter.woff2"
          as="font"
          type="font/woff2"
          crossOrigin="anonymous"
        />
      </head>
      <body>{children}</body>
    </html>
  );
}

Service Worker for Caching

// public/sw.js
const CACHE_NAME = 'v1';
const urlsToCache = [
  '/',
  '/styles/main.css',
  '/scripts/main.js',
];

self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(CACHE_NAME).then((cache) => cache.addAll(urlsToCache))
  );
});

self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request).then((response) => response || fetch(event.request))
  );
});

CSS Optimization

Critical CSS Inlining

// Next.js automatically inlines critical CSS
// For other setups, use critical CSS extraction

import critical from 'critical';

critical.generate({
  inline: true,
  base: 'dist/',
  src: 'index.html',
  dest: 'index.html',
  width: 1300,
  height: 900,
});

CSS-in-JS Performance

// ❌ Slow: Runtime CSS-in-JS (styled-components, emotion)
import styled from 'styled-components';

const Button = styled.button`
  background: blue;
  padding: 10px;
`;

// ✅ Fast: Zero-runtime CSS-in-JS (vanilla-extract, linaria)
import { style } from '@vanilla-extract/css';

export const button = style({
  background: 'blue',
  padding: 10,
});

// ✅ Fastest: Tailwind CSS (utility-first, no runtime)
<button className="bg-blue-500 px-4 py-2">Click me</button>

Monitoring & Continuous Improvement

Performance Budget

// webpack.config.js
module.exports = {
  performance: {
    maxEntrypointSize: 250000, // 250 KB
    maxAssetSize: 250000,
    hints: 'error',
  },
};

// Next.js performance budgets in next.config.js
const nextConfig = {
  webpack(config) {
    config.performance = {
      maxEntrypointSize: 250000,
      maxAssetSize: 250000,
    };
    return config;
  },
};

Automated Lighthouse CI

# .github/workflows/lighthouse.yml
name: Lighthouse CI
on: [push]

jobs:
  lighthouse:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - name: Run Lighthouse CI
        uses: treosh/lighthouse-ci-action@v9
        with:
          urls: |
            https://your-site.com/
            https://your-site.com/about
          budgetPath: ./budget.json
          uploadArtifacts: true

Checklist

Build Time

  • [ ] Enable minification
  • [ ] Enable tree shaking
  • [ ] Analyze bundle size
  • [ ] Remove unused dependencies
  • [ ] Use production builds

Images

  • [ ] Use Next.js Image or responsive images
  • [ ] Implement lazy loading
  • [ ] Use WebP/AVIF formats
  • [ ] Add blur placeholders
  • [ ] Optimize image sizes

Code

  • [ ] Code splitting by route
  • [ ] Lazy load heavy components
  • [ ] Memoize expensive calculations
  • [ ] Virtualize long lists
  • [ ] Avoid unnecessary re-renders

Network

  • [ ] Enable HTTP/2
  • [ ] Use CDN
  • [ ] Enable compression (gzip/brotli)
  • [ ] Implement caching headers
  • [ ] Prefetch critical resources

Fonts

  • [ ] Use font-display: swap
  • [ ] Subset fonts
  • [ ] Preload fonts
  • [ ] Use system fonts when possible

💡Key Takeaways

  • 1Measure first with Lighthouse and Web Vitals - you cannot optimize what you do not measure
  • 2Code splitting reduces initial bundle from 3.2MB to 180KB (94% reduction)
  • 3Image optimization has the biggest impact on LCP - use Next.js Image component
  • 4Lazy load below-the-fold content - prioritize what users see first
  • 5Memoization (React.memo, useMemo, useCallback) prevents unnecessary re-renders
  • 6Server components reduce client JS by 60% (Next.js App Router)
  • 7Monitor continuously - performance degrades over time without vigilance
  • 8Core Web Vitals impact SEO - poor performance = lower search rankings

Performance is a feature, not a nice-to-have. Users notice the difference.