React Performance Optimization: From 3s to 0.8s Load Time
I spent three weeks last month staring at a waterfall chart wondering why our CitizenApp dashboard was taking 3+ seconds to load on 4G. The browser was doing something, but what? Turns out, I was shipping 280KB of JavaScript that users didn't need on the first page. This is the story of how we cut that by 85%.
The Problem: Measuring First
Before optimizing anything, I profiled. This burned me once before—I optimized the wrong thing and saved 0.3s on a 50ms bottle neck. Never again.
I used Lighthouse in Chrome DevTools and Web Vitals on production:
// monitoring/vitals.ts
import { getCLS, getFID, getFCP, getLCP, getTTFB } from 'web-vitals';
export function initWebVitals() {
getCLS(console.log);
getFID(console.log);
getFCP(console.log);
getLCP(console.log);
getTTFB(console.log);
}
Deploy this to production first. Use a real performance monitoring tool (I recommend Sentry, Vercel Analytics, or Cloudflare Web Analytics). Synthetic tests lie—production data doesn't.
Our baseline metrics:
- First Contentful Paint: 1.8s
- Largest Contentful Paint: 3.1s
- Time to Interactive: 3.4s
- Bundle size: 420KB (gzipped: 120KB)
The LCP was the culprit. Users saw a blank screen for 3+ seconds.
Technique 1: Code Splitting with React.lazy()
This is the easiest win. I prefer React.lazy() over dynamic imports for simplicity, but both work.
Our dashboard has 9 AI features, each with its own page. Nobody needs all 9 on load. Split them.
// routes.tsx
import { Suspense } from 'react';
import { lazy } from 'react';
// Before: import all upfront
// import AnalyticsPage from './pages/Analytics';
// import DocumentsPage from './pages/Documents';
// import AIAssistantPage from './pages/AIAssistant';
// After: lazy load
const AnalyticsPage = lazy(() => import('./pages/Analytics'));
const DocumentsPage = lazy(() => import('./pages/Documents'));
const AIAssistantPage = lazy(() => import('./pages/AIAssistant'));
export function Routes() {
return (
<>
<Route path="/analytics" element={
<Suspense fallback={<LoadingSpinner />}>
<AnalyticsPage />
</Suspense>
} />
<Route path="/documents" element={
<Suspense fallback={<LoadingSpinner />}>
<DocumentsPage />
</Suspense>
} />
<Route path="/ai-assistant" element={
<Suspense fallback={<LoadingSpinner />}>
<AIAssistantPage />
</Suspense>
} />
</>
);
}
Why this matters: Instead of bundling 280KB of code upfront, we ship 50KB initially. The other 230KB downloads when the user navigates. They don't wait for it if they never go there.
Result: Bundle size down to 85KB (gzipped), LCP: 1.2s
Technique 2: Prefetch Smart Routes
Code splitting helps, but now navigation feels slow because the chunk downloads on demand. I use prefetching—but only for likely next pages.
// hooks/usePrefetch.ts
import { useEffect } from 'react';
import { useLocation } from 'react-router-dom';
const routePrefetchMap: Record<string, string[]> = {
'/dashboard': ['/analytics', '/documents'],
'/analytics': ['/ai-assistant'],
'/documents': ['/ai-assistant'],
};
export function usePrefetch() {
const location = useLocation();
useEffect(() => {
const nextRoutes = routePrefetchMap[location.pathname] || [];
nextRoutes.forEach((route) => {
const link = document.createElement('link');
link.rel = 'prefetch';
link.href = `/_next/static/chunks/${route.slice(1)}.js`;
document.head.appendChild(link);
});
}, [location.pathname]);
}
This prefetches chunks for probable next routes in the background. Browser downloads them during idle time (if bandwidth available).
Result: Navigation feels instant. 0.1s vs 0.8s for route transition.
Technique 3: Tree Shaking Dependencies
This one saved us 40KB. Our Claude integration had unused dependencies.
// ai/claude.ts - BEFORE
import Anthropic from '@anthropic-ai/sdk'; // 45KB
import { LLM } from '@anthropic-ai/sdk'; // unused
// After: Only import what we need
import Anthropic from '@anthropic-ai/sdk';
// Better yet, use a lighter alternative for client-side
// We switched to native fetch + Anthropic REST API
export async function askAI(message: string) {
const response = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: {
'x-api-key': process.env.REACT_APP_ANTHROPIC_KEY,
'content-type': 'application/json',
},
body: JSON.stringify({
model: 'claude-3-5-sonnet-20241022',
max_tokens: 1024,
messages: [{ role: 'user', content: message }],
}),
});
return response.json();
}
Why the REST approach? The SDK is designed for Node. We don't need it on the client—native fetch is 1KB vs 45KB.
I use bundlephobia.com and Webpack Bundle Analyzer to find these culprits:
npm install --save-dev webpack-bundle-analyzer
Then in your build:
// webpack.config.js or vite.config.ts
import { BundleAnalyzerPlugin } from 'webpack-bundle-analyzer';
plugins: [
new BundleAnalyzerPlugin(),
]
Result: 40KB reduction, LCP: 1.0s
Technique 4: Image Optimization
This is where most apps leak performance. I prefer Next.js Image or a CDN like Cloudflare.
// components/HeroImage.tsx
// BEFORE: raw img tag
// <img src="/hero.png" alt="hero" />
// AFTER: Cloudflare Image Optimization
export function HeroImage() {
return (
<img
src="https://cdn.example.com/hero.png?format=webp&width=1200&quality=80"
srcSet="
https://cdn.example.com/hero.png?format=webp&width=600&quality=80 600w,
https://cdn.example.com/hero.png?format=webp&width=1200&quality=80 1200w
"
alt="hero"
loading="lazy"
decoding="async"
/>
);
}
Cloudflare automatically compresses and serves from edge. loading="lazy" delays off-screen images.
Result: 30KB reduction in initial image load, LCP: 0.8s
Final Metrics
Before:
- Bundle: 420KB (gzipped: 120KB)
- FCP: 1.8s
- LCP: 3.1s
- TTI: 3.4s
After:
- Bundle: 85KB initial (gzipped: 28KB)
- FCP: 0.6s
- LCP: 0.8s
- TTI: 1.2s
Improvement: 73% faster LCP, 73% smaller initial bundle
Gotcha: Prefetch Too Aggressively
I prefetched every route like an overeager developer. On 4G, this killed performance because the browser downloaded 200KB of unused code. Be selective. Only prefetch high-probability routes.
Also: prefetch links, not full pages. Let the user control navigation.
Gotcha: Lazy Loading Everything
I tried lazy loading the navbar (bad idea). Always keep above-the-fold and UX-critical components in the main bundle. Only lazy load secondary features and routes.
Gotcha: Ignoring Third-Party Scripts
Our Stripe integration added 15KB and wasn't code-split. Always defer third-party scripts and use async/defer attributes.
// utils/loadScript.ts
export function loadScript(src: string, options = {}) {
return new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = src;
script.async = true;
script.defer = true;
script.onload = resolve;
script.onerror = reject;
document.head.appendChild(script);
});
}
Takeaway
Performance optimization is measurement-driven. Profile first, optimize second. The biggest wins come from code splitting and smart prefetching, not micro-optimizations. Get your LCP under 2.5s, and users feel your app is fast.
Use Lighthouse, deploy monitoring, and repeat quarterly. Performance regressions creep in fast.
Top comments (0)