
Optimizing React Performance: Lessons from Building a Fast Portfolio
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 buildKey Findings from My Analysis
-
Lucide React Icons: 45KB for unused icons
- Solution: Use direct imports:
import { Github } from 'lucide-react'
- Solution: Use direct imports:
-
Syntax Highlighter: 120KB for Prism.js
- Solution: Switched to lighter alternative (Shiki) with tree-shaking
-
Date Libraries: 68KB for date-fns
- Solution: Used native
Intl.DateTimeFormatinstead
- Solution: Used native
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:
- Measure first: Use Lighthouse and bundle analyzer to find bottlenecks
- Optimize images: Biggest impact for least effort
- Split code: Load only what users need
- Server-first: Let the server do the heavy lifting
- Monitor continuously: Track real user metrics
The result? A fast, delightful user experience that converts better and ranks higher in search.