Skip to main content
Back to Blog
Optimizing React Performance: Lessons from Building a Fast Portfolio
technicalFeatured

Optimizing React Performance: Lessons from Building a Fast Portfolio

6 min read

Practical techniques for optimizing React performance including code splitting, lazy loading, memoization, and bundle analysis. Real metrics from achieving 99+ Lighthouse scores.

Introduction

When I rebuilt my portfolio with Next.js 15, I set an ambitious goal: achieve 95+ Lighthouse performance scores while maintaining a rich, interactive user experience. After weeks of optimization, I consistently hit 98-99 scores with sub-1.5s First Contentful Paint (FCP).

Here are the key techniques that made the biggest impact.

Baseline Metrics: Where We Started

Initial build (before optimization):

  • Lighthouse Performance: 72
  • FCP: 2.8s
  • LCP: 4.2s
  • Total Bundle Size: 285KB
  • Time to Interactive: 4.5s

After optimization:

  • Lighthouse Performance: 98
  • FCP: 1.2s
  • LCP: 1.8s
  • Total Bundle Size: 142KB
  • Time to Interactive: 2.1s

Let's break down how we achieved these improvements.

1. Image Optimization: The Low-Hanging Fruit

Images accounted for 60% of initial page weight. Here's what worked:

Use Next.js Image Component

// ❌ Before: Unoptimized images
<img src="/images/hero.jpg" alt="Hero" />
 
// ✅ After: Automatic optimization
import Image from 'next/image';
 
<Image
  src="/images/hero.jpg"
  alt="Hero"
  width={800}
  height={600}
  priority // Load above-the-fold images first
  placeholder="blur"
  blurDataURL="data:image/jpeg;base64,/9j/4AAQSkZJRg..." // Generate with plaiceholder
/>

Impact: Saved 180KB on hero image alone (JPEG → WebP + compression)

Responsive Images with srcSet

<Image
  src="/images/project.jpg"
  alt="Project screenshot"
  width={1200}
  height={675}
  sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
/>

Next.js automatically generates multiple sizes and serves the optimal one per device.

Lazy Load Below-the-Fold Images

// Hero image: Priority loading
<Image src="/hero.jpg" priority />
 
// Project thumbnails: Lazy load
<Image src="/project-1.jpg" loading="lazy" />

Result: FCP improved from 2.8s → 1.6s

2. Code Splitting: Load Only What You Need

Dynamic Imports for Heavy Components

import dynamic from 'next/dynamic';
 
// ❌ Before: Modal always loaded
import { ProjectModal } from '@/components/shared/ProjectModal';
 
// ✅ After: Only load when needed
const ProjectModal = dynamic(
  () => import('@/components/shared/ProjectModal').then(mod => ({ default: mod.ProjectModal })),
  { ssr: false } // Client-only component
);

Impact: Reduced initial bundle by 28KB

Route-Based Code Splitting

Next.js App Router automatically splits code per route:

app/
├── page.tsx           → home.chunk.js (142KB)
├── blog/
│   └── page.tsx       → blog.chunk.js (38KB)
└── blog/[slug]/
    └── page.tsx       → blog-post.chunk.js (52KB)

Users only download code for pages they visit.

3. Memoization: Prevent Unnecessary Re-renders

React.memo for Expensive Components

import { memo } from 'react';
 
// Component renders frequently due to parent re-renders
export const ProjectCard = memo(({ project }: { project: Project }) => {
  return (
    <div className="project-card">
      <h3>{project.title}</h3>
      <p>{project.description}</p>
    </div>
  );
}, (prevProps, nextProps) => {
  // Custom comparison: Only re-render if project ID changes
  return prevProps.project.id === nextProps.project.id;
});

useMemo for Expensive Calculations

import { useMemo } from 'react';
 
export function Projects() {
  const [projects, setProjects] = useState<Project[]>([]);
  const [filter, setFilter] = useState<string | null>(null);
 
  // ❌ Before: Re-calculated on every render
  // const filteredProjects = projects.filter(p =>
  //   filter ? p.technologies.includes(filter) : true
  // );
 
  // ✅ After: Only recalculate when dependencies change
  const filteredProjects = useMemo(() => {
    return projects.filter(p =>
      filter ? p.technologies.includes(filter) : true
    );
  }, [projects, filter]);
 
  return <div>{filteredProjects.map(p => <ProjectCard key={p.id} project={p} />)}</div>;
}

Impact: Eliminated 200+ unnecessary re-renders per interaction

4. Bundle Analysis: Find the Fat

Use Next.js Bundle Analyzer to visualize what's bloating your bundle:

# Install analyzer
bun add -D @next/bundle-analyzer
 
# next.config.ts
import withBundleAnalyzer from '@next/bundle-analyzer';
 
const bundleAnalyzer = withBundleAnalyzer({
  enabled: process.env.ANALYZE === 'true',
});
 
export default bundleAnalyzer(nextConfig);
 
# Run analysis
ANALYZE=true bun run build

Key Findings from My Analysis

  1. Lucide React Icons: 45KB for unused icons

    • Solution: Use direct imports: import { Github } from 'lucide-react'
  2. Syntax Highlighter: 120KB for Prism.js

    • Solution: Switched to lighter alternative (Shiki) with tree-shaking
  3. Date Libraries: 68KB for date-fns

    • Solution: Used native Intl.DateTimeFormat instead

Total Savings: 128KB from bundle analysis alone

5. Server Components: Shift Work to the Server

Next.js 15 App Router defaults to Server Components - leverage them!

// ✅ Server Component: No JavaScript sent to client
export default async function ProjectPage({ params }: { params: { slug: string } }) {
  // This runs on the server
  const project = await getProjectBySlug(params.slug);
 
  return (
    <div>
      <h1>{project.title}</h1>
      <p>{project.description}</p>
      {/* Static HTML, no hydration needed */}
    </div>
  );
}

Only use Client Components when needed:

'use client'; // Required for interactivity
 
import { useState } from 'react';
 
export function ContactForm() {
  const [isSubmitting, setIsSubmitting] = useState(false);
  // ... form logic
}

Impact: Reduced client-side JavaScript by 64KB

6. Web Fonts: Optimize Typography

Use next/font for Automatic Optimization

import { Geist } from 'next/font/google';
 
const geistSans = Geist({
  variable: '--font-geist-sans',
  subsets: ['latin'],
  display: 'swap', // Prevent FOIT (Flash of Invisible Text)
});
 
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en" className={geistSans.variable}>
      <body>{children}</body>
    </html>
  );
}

Benefits:

  • Fonts self-hosted (no external requests)
  • Automatic subsetting (only characters used)
  • CSS variable injection (no FOUT)

7. Third-Party Scripts: Defer Non-Critical Code

Load Analytics After Page Interactive

// ❌ Before: Blocking analytics
<script src="https://cdn.analytics.com/script.js" />
 
// ✅ After: Load after page interactive
import Script from 'next/script';
 
export function Analytics() {
  return (
    <Script
      src="https://cdn.analytics.com/script.js"
      strategy="afterInteractive" // Load after page is interactive
    />
  );
}

Lazy Load YouTube Embeds

import dynamic from 'next/dynamic';
 
// lite-youtube-embed: 3KB vs 1.2MB for full YouTube player
const LiteYouTubeEmbed = dynamic(() => import('react-lite-youtube-embed'), {
  ssr: false,
});
 
export function Video({ videoId }: { videoId: string }) {
  return <LiteYouTubeEmbed id={videoId} title="Video" />;
}

Impact: Saved 1.2MB per video embed

8. Prefetching: Anticipate User Behavior

Next.js automatically prefetches links in viewport:

import Link from 'next/link';
 
// Automatically prefetches /blog when link is visible
<Link href="/blog" prefetch={true}>
  Read Blog
</Link>

Disable for low-priority pages:

<Link href="/archive" prefetch={false}>
  View Archive
</Link>

9. Measuring Performance: Real User Monitoring

Track Core Web Vitals in production:

// app/layout.tsx
export function reportWebVitals(metric: NextWebVitalsMetric) {
  if (metric.label === 'web-vital') {
    // Send to analytics
    analytics.track('Web Vital', {
      name: metric.name,
      value: metric.value,
      id: metric.id,
    });
  }
}

Monitor in PostHog dashboard:

  • FCP: < 1.5s (90th percentile)
  • LCP: < 2.5s (90th percentile)
  • CLS: < 0.1 (90th percentile)

Performance Checklist

✅ Use Next.js Image component with priority for above-the-fold images ✅ Lazy load below-the-fold content ✅ Dynamic import heavy components ✅ Memoize expensive calculations and components ✅ Analyze bundle and remove unused dependencies ✅ Prefer Server Components over Client Components ✅ Optimize fonts with next/font ✅ Defer third-party scripts ✅ Monitor real user metrics

Conclusion

Achieving 98+ Lighthouse scores isn't magic - it's systematic optimization:

  1. Measure first: Use Lighthouse and bundle analyzer to find bottlenecks
  2. Optimize images: Biggest impact for least effort
  3. Split code: Load only what users need
  4. Server-first: Let the server do the heavy lifting
  5. Monitor continuously: Track real user metrics

The result? A fast, delightful user experience that converts better and ranks higher in search.

Resources

Tags

reactperformancenextjsoptimizationweb-vitals