DEV Community

Ugur Aslim
Ugur Aslim

Posted on • Originally published at uguraslim.com

React Performance Optimization: From 3s to 0.8s Load Time

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);
}
Enter fullscreen mode Exit fullscreen mode

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>
      } />
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

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]);
}
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Then in your build:

// webpack.config.js or vite.config.ts
import { BundleAnalyzerPlugin } from 'webpack-bundle-analyzer';

plugins: [
  new BundleAnalyzerPlugin(),
]
Enter fullscreen mode Exit fullscreen mode

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"
    />
  );
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
  });
}
Enter fullscreen mode Exit fullscreen mode

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)