DEV Community

Cover image for React Performance Optimization: How I Reduced Load Time by 20%
Haris Siddiqui
Haris Siddiqui

Posted on

React Performance Optimization: How I Reduced Load Time by 20%

The Problem

During my time as a ReactJS Frontend Developer, I inherited a React application that was frustratingly slow. Users were complaining about long loading times, and our bounce rate was climbing. The stakeholders gave me a clear challenge: "Make it faster, or we'll consider a complete rewrite."

The numbers were brutal:

  • Initial load time: ~4.2 seconds
  • Time to Interactive (TTI): ~6.8 seconds
  • Bundle size: 2.1 MB
  • Lighthouse Performance Score: 34/100

After implementing the strategies I'll share below, we achieved:

  • Load time: 3.1 seconds (26% improvement)
  • TTI: 4.9 seconds (28% improvement)
  • Bundle size: 1.4 MB (33% reduction)
  • Lighthouse Score: 78/100 (129% improvement)

Let me walk you through exactly how we did it.

Step 1: Measure First, Optimize Second

Before touching any code, I established baseline metrics using multiple tools:

# Install performance monitoring tools npm install --save-dev webpack-bundle-analyzer npm install --save-dev lighthouse 
Enter fullscreen mode Exit fullscreen mode

Measurement Setup:

// webpack.config.js const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin; module.exports = { // ... other config plugins: [ process.env.ANALYZE && new BundleAnalyzerPlugin() ].filter(Boolean) }; // package.json { "scripts": { "analyze": "ANALYZE=true npm run build" } } 
Enter fullscreen mode Exit fullscreen mode

Key insight: You can't improve what you don't measure. The bundle analyzer immediately showed that our largest chunk was a charting library we barely used.

Step 2: Code Splitting and Lazy Loading

The biggest win came from splitting our monolithic bundle:

Before:

// App.js - Everything imported upfront import Dashboard from './components/Dashboard'; import Analytics from './components/Analytics'; import Reports from './components/Reports'; import Settings from './components/Settings'; function App() { return ( <Router> <Routes> <Route path="/dashboard" element={<Dashboard />} />  <Route path="/analytics" element={<Analytics />} />  <Route path="/reports" element={<Reports />} />  <Route path="/settings" element={<Settings />} />  </Routes>  </Router>  ); } 
Enter fullscreen mode Exit fullscreen mode

After:

// App.js - Lazy load route components import { lazy, Suspense } from 'react'; const Dashboard = lazy(() => import('./components/Dashboard')); const Analytics = lazy(() => import('./components/Analytics')); const Reports = lazy(() => import('./components/Reports')); const Settings = lazy(() => import('./components/Settings')); function App() { return ( <Router> <Suspense fallback={<div className="loading-spinner">Loading...</div>}>  <Routes> <Route path="/dashboard" element={<Dashboard />} />  <Route path="/analytics" element={<Analytics />} />  <Route path="/reports" element={<Reports />} />  <Route path="/settings" element={<Settings />} />  </Routes>  </Suspense>  </Router>  ); } 
Enter fullscreen mode Exit fullscreen mode

Result: Initial bundle size reduced from 2.1MB to 1.2MB immediately.

Step 3: Component-Level Optimizations

Memoization Strategy

I identified components that were re-rendering unnecessarily:

// Before - Re-renders on every parent update const UserCard = ({ user, onEdit }) => { return ( <div className="user-card"> <img src={user.avatar} alt={user.name} />  <h3>{user.name}</h3>  <button onClick={() => onEdit(user.id)}>Edit</button>  </div>  ); }; // After - Only re-renders when user data changes const UserCard = React.memo(({ user, onEdit }) => { return ( <div className="user-card"> <img src={user.avatar} alt={user.name} />  <h3>{user.name}</h3>  <button onClick={() => onEdit(user.id)}>Edit</button>  </div>  ); }, (prevProps, nextProps) => { return prevProps.user.id === nextProps.user.id && prevProps.user.name === nextProps.user.name; }); 
Enter fullscreen mode Exit fullscreen mode

useMemo for Expensive Calculations

// Before - Recalculated on every render const Dashboard = ({ data }) => { const processedData = data.map(item => ({ ...item, calculations: heavyCalculation(item) })); return <Chart data={processedData} />; }; // After - Only recalculated when data changes const Dashboard = ({ data }) => { const processedData = useMemo(() => data.map(item => ({ ...item, calculations: heavyCalculation(item) })), [data] ); return <Chart data={processedData} />; }; 
Enter fullscreen mode Exit fullscreen mode

Step 4: Image Optimization

Images were a major bottleneck. Here's what worked:

// Custom hook for progressive image loading const useProgressiveImage = (src) => { const [loading, setLoading] = useState(true); const [imgSrc, setImgSrc] = useState(null); useEffect(() => { const img = new Image(); img.onload = () => { setImgSrc(src); setLoading(false); }; img.src = src; }, [src]); return { loading, imgSrc }; }; // Component usage const ImageComponent = ({ src, placeholder, alt }) => { const { loading, imgSrc } = useProgressiveImage(src); return ( <div className="image-container"> {loading ? ( <img src={placeholder} alt={alt} className="placeholder" /> ) : ( <img src={imgSrc} alt={alt} className="loaded" /> )} </div>  ); }; 
Enter fullscreen mode Exit fullscreen mode

Step 5: API Call Optimization

Request Deduplication

// Custom hook to prevent duplicate API calls const useApiCall = (url) => { const [data, setData] = useState(null); const [loading, setLoading] = useState(false); const requestCache = useRef(new Map()); const fetchData = useCallback(async () => { if (requestCache.current.has(url)) { return requestCache.current.get(url); } setLoading(true); try { const response = await fetch(url); const result = await response.json(); requestCache.current.set(url, result); setData(result); return result; } catch (error) { console.error('API call failed:', error); } finally { setLoading(false); } }, [url]); return { data, loading, fetchData }; }; 
Enter fullscreen mode Exit fullscreen mode

Data Prefetching

// Prefetch data for likely next pages const Dashboard = () => { const { data: currentData } = useApiCall('/api/dashboard'); useEffect(() => { // Prefetch analytics data (user likely to visit next) const timer = setTimeout(() => { fetch('/api/analytics').then(response => response.json()); }, 2000); return () => clearTimeout(timer); }, []); return <DashboardContent data={currentData} />; }; 
Enter fullscreen mode Exit fullscreen mode

Step 6: Bundle Optimization

Tree Shaking Improvements

// Before - Importing entire library import _ from 'lodash'; import moment from 'moment'; // After - Import only what you need import { debounce, throttle } from 'lodash'; import dayjs from 'dayjs'; // Smaller alternative to moment 
Enter fullscreen mode Exit fullscreen mode

Dynamic Imports for Heavy Libraries

// Load heavy chart library only when needed const ChartComponent = ({ data }) => { const [ChartLibrary, setChartLibrary] = useState(null); useEffect(() => { import('react-chartjs-2').then((module) => { setChartLibrary(() => module.Line); }); }, []); if (!ChartLibrary) { return <div>Loading chart...</div>;  } return <ChartLibrary data={data} />; }; 
Enter fullscreen mode Exit fullscreen mode

Step 7: Service Worker Implementation

// public/sw.js - Basic caching strategy const CACHE_NAME = 'app-v1'; const urlsToCache = [ '/', '/static/css/main.css', '/static/js/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)) ); }); 
Enter fullscreen mode Exit fullscreen mode

Measuring the Impact

Performance Monitoring Setup:

// utils/performance.js export const measureComponentPerformance = (componentName) => { return function(WrappedComponent) { return function MeasuredComponent(props) { useEffect(() => { const startTime = performance.now(); return () => { const endTime = performance.now(); console.log(`${componentName} render time: ${endTime - startTime}ms`); }; }); return <WrappedComponent {...props} />;  }; }; }; // Usage export default measureComponentPerformance('Dashboard')(Dashboard); 
Enter fullscreen mode Exit fullscreen mode

The Results

After implementing these optimizations over 3 weeks:

Metric Before After Improvement
Load Time 4.2s 3.1s 26% faster
Time to Interactive 6.8s 4.9s 28% faster
Bundle Size 2.1MB 1.4MB 33% smaller
Lighthouse Score 34/100 78/100 129% better

Key Lessons Learned

  1. Measure everything - You can't optimize what you can't see
  2. Start with the biggest wins - Code splitting gave us immediate 40% bundle reduction
  3. Don't over-optimize - Some micro-optimizations aren't worth the complexity
  4. User experience matters most - Sometimes a loading spinner is better than a frozen UI

Tools That Made the Difference

  • React DevTools Profiler - Identifying slow components
  • Webpack Bundle Analyzer - Visualizing bundle composition
  • Chrome DevTools - Network and performance analysis
  • Lighthouse CI - Automated performance testing

What's Next?

  • Implementing React 18's concurrent features
  • Exploring React Server Components
  • Adding more sophisticated caching strategies
  • A/B testing different loading patterns

Performance optimization is a journey, not a destination. The techniques above got us significant improvements, but there's always more to discover.

What performance challenges are you facing in your React apps? Share your experiences in the comments!

Tags

#react #performance #optimization #webdev #javascript #frontend #lighthouse

Top comments (0)