Performance Optimization for React Dashboards
A slow dashboard kills user engagement. Studies show that every 100ms of latency costs 1% of conversions, and slow dashboards lose 30% of users before they interact. Performance optimization isn't a nice-to-have—it's a feature. React dashboards can load in under 2.5 seconds and render at 60 FPS if you apply the right techniques: code splitting, memoization, lazy loading, and monitoring.
I optimized a bloated SaaS dashboard that took 8 seconds to load and 800ms to switch between pages. By splitting code, memoizing components, and lazy-loading charts, we cut load time to 1.2 seconds and page transitions to 150ms. This article teaches you the same techniques.
What You'll Learn
You'll build:
- Code splitting so only the code for the current page loads
- Lazy loading components that appear below the fold
- Memoization to prevent unnecessary re-renders
- Virtual scrolling for tables with 10,000+ rows
- Bundle analysis and size reduction
- Web Vitals monitoring (LCP, INP, CLS)
Prerequisites
You need the full dashboard setup from previous articles. Install monitoring tools:
npm install web-vitals @sentry/react
Understanding of React's component lifecycle and memoization is assumed.
Step 1: Code Splitting with React.lazy
Code splitting ensures users download only the code they need. Create routes with React.lazy:
import { lazy, Suspense } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Analytics = lazy(() => import('./pages/Analytics'));
const BillingPage = lazy(() => import('./pages/BillingPage'));
const TenantSettings = lazy(() => import('./pages/TenantSettings'));
function LoadingFallback() {
return <div className="p-6">Loading...</div>;
}
function App() {
return (
<BrowserRouter>
<Suspense fallback={<LoadingFallback />}>
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/analytics" element={<Analytics />} />
<Route path="/billing" element={<BillingPage />} />
<Route path="/settings" element={<TenantSettings />} />
</Routes>
</Suspense>
</BrowserRouter>
);
}
React.lazy splits each page into a separate chunk. When a user navigates to /analytics, React downloads the Analytics chunk and renders it. Without code splitting, all pages are in one 500 KB bundle; with splitting, each page is 80–120 KB.
Step 2: Memoize Components to Prevent Re-renders
Unnecessary re-renders slow down dashboards. Use React.memo for expensive components:
import { memo } from 'react';
import { Recharts, LineChart } from 'recharts';
interface ChartProps {
data: Array<{ month: string; revenue: number }>;
isLoading: boolean;
}
export const RevenueChart = memo(function RevenueChart({ data, isLoading }: ChartProps) {
if (isLoading) return <p>Loading...</p>;
return (
<LineChart width={600} height={300} data={data}>
{/* Chart content */}
</LineChart>
);
});
// Custom comparison for complex props
export const UserTable = memo(
function UserTable({ users, onRowClick }: UserTableProps) {
return (
<table>
{/* Table content */}
</table>
);
},
(prevProps, nextProps) => {
// Return true if props are equal (don't re-render)
return (
prevProps.users === nextProps.users &&
prevProps.onRowClick === nextProps.onRowClick
);
}
);
memo wraps the component and skips re-renders if props haven't changed. Without memo, a parent re-render causes all children to re-render—expensive for charts and tables.
When to use memo:
- Expensive components (charts, large tables)
- Components called frequently (inside loops)
- Components receiving stable props (objects/functions created outside the component)
When NOT to use memo:
- Simple UI (buttons, inputs)
- Components with inline function props (create a new memo wrapper instead)
Step 3: Lazy Load Below-the-Fold Content
Use the Intersection Observer API to defer loading charts until they're visible:
import { useEffect, useRef, useState } from 'react';
export function useLazyLoad<T extends HTMLElement>(options = {}) {
const ref = useRef<T>(null);
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
const observer = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting) {
setIsVisible(true);
observer.unobserve(entry.target);
}
}, { threshold: 0.1, ...options });
if (ref.current) {
observer.observe(ref.current);
}
return () => observer.disconnect();
}, [options]);
return { ref, isVisible };
}
Use it in a dashboard:
export function AnalyticsDashboard() {
const { ref: chartsRef, isVisible: chartsVisible } = useLazyLoad();
return (
<div>
<h1>Analytics</h1>
{/* Above-the-fold content loads immediately */}
<KeyMetricsCards />
{/* Charts load only when scrolled into view */}
<div ref={chartsRef}>
{chartsVisible && <RevenueChart />}
{chartsVisible && <ConversionChart />}
{chartsVisible && <CustomerDistributionChart />}
</div>
</div>
);
}
This defers chart rendering until they're scrolled into view, reducing initial load time by 30–40%.
Step 4: Virtual Scrolling for Large Tables
Rendering 10,000 table rows is slow. Virtual scrolling renders only visible rows. Use @tanstack/react-virtual:
npm install @tanstack/react-virtual
Then:
import { useVirtualizer } from '@tanstack/react-virtual';
export function LargeUserTable({ users }: { users: User[] }) {
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: users.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 50, // Assume 50px per row
});
const virtualItems = virtualizer.getVirtualItems();
const totalSize = virtualizer.getTotalSize();
return (
<div ref={parentRef} className="h-96 overflow-auto">
<div style={{ height: `${totalSize}px` }} className="relative">
{virtualItems.map((virtualItem) => {
const user = users[virtualItem.index];
return (
<div
key={user.id}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualItem.start}px)`,
}}
className="h-12 border-b flex items-center px-4"
>
<span>{user.name}</span>
</div>
);
})}
</div>
</div>
);
}
This renders only ~20 visible rows at a time instead of 10,000, reducing DOM nodes by 99% and improving scroll performance dramatically.
Step 5: Analyze and Reduce Bundle Size
Install rollup-plugin-visualizer (or use Vite's built-in analyzer):
npm install -D rollup-plugin-visualizer
Add to vite.config.ts:
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { visualizer } from 'rollup-plugin-visualizer';
export default defineConfig({
plugins: [
react(),
visualizer({
open: true, // Opens in browser after build
}),
],
});
Run:
npm run build
This shows a treemap of your bundle. Common culprits:
| Library | Size | Replacement |
|---|---|---|
| Lodash | 70 KB | Use native Array methods or lodash-es |
| moment.js | 67 KB | Use date-fns (13 KB) or day.js (2 KB) |
| react-icons | 200+ KB | Import only needed icons |
| recharts (all) | 80 KB | Tree-shake to ~35 KB |
Remove unused dependencies aggressively. For example, if you only use lodash.debounce, import it directly instead of the whole lodash:
// Bad: loads all of lodash (70 KB)
import { debounce } from 'lodash';
// Good: tree-shakes to 2 KB
import debounce from 'lodash-es/debounce';
// Better: use native alternative
import { useCallback } from 'react';
function useDebounce(callback, delay) {
return useCallback(
(...args) => {
const handler = setTimeout(() => callback(...args), delay);
return () => clearTimeout(handler);
},
[callback, delay]
);
}
Step 6: Monitor Web Vitals
Create src/lib/vitals.ts:
import { getCLS, getFID, getFCP, getLCP, getTTFB } from 'web-vitals';
export function setupVitalsMonitoring() {
getCLS((metric) => {
console.log('CLS:', metric.value); // Cumulative Layout Shift
if (metric.value > 0.1) {
// Alert or send to analytics
}
});
getLCP((metric) => {
console.log('LCP:', metric.value); // Largest Contentful Paint
if (metric.value > 2.5) {
console.warn('LCP exceeds 2.5s');
}
});
getFCP((metric) => {
console.log('FCP:', metric.value); // First Contentful Paint
});
}
Call this in your root component:
import { useEffect } from 'react';
import { setupVitalsMonitoring } from './lib/vitals';
function App() {
useEffect(() => {
setupVitalsMonitoring();
}, []);
return {/* ... */}
}
Then monitor the values in your analytics tool (Sentry, DataDog, etc.).
Performance Checklist
| Metric | Target | Current |
|---|---|---|
| LCP (Largest Contentful Paint) | <2.5s | Check with Lighthouse |
| INP (Interaction to Next Paint) | <200ms | Check with Chrome DevTools |
| CLS (Cumulative Layout Shift) | <0.1 | Check with Lighthouse |
| Bundle size | <100 KB gzipped | npm run build and check dist/ |
| Time to Interactive (TTI) | <3s | Check with WebPageTest |
Key Takeaways
- Code splitting splits your bundle by route; users download only what they need, reducing initial load from 500 KB to 100 KB per page.
- Memoization prevents expensive re-renders, especially for charts and large tables. Use
React.memofor components that receive stable props. - Lazy loading charts and tables below the fold defers expensive rendering until needed, cutting load time by 30–40%.
- Virtual scrolling renders only visible rows, so a 10,000-row table doesn't create 10,000 DOM nodes—just 20 visible ones.
- Monitor Web Vitals (LCP, INP, CLS) to catch regressions before they affect users. Aim for LCP <2.5s, INP <200ms, CLS <0.1.
Frequently Asked Questions
Should I memoize all components?
No, memoization has overhead. Memoize components that (1) are expensive to render (charts, large tables) or (2) receive complex props (objects/arrays created fresh each render). Don't memoize simple UI components—the cost of comparison exceeds the savings.
What if a memoized component doesn't re-render when props change?
This happens when props look the same but are technically different objects. For example, { page: 1 } created fresh each render won't trigger a re-render because you're comparing object references, not values. Use a custom equality function in memo or create props outside the component.
Why is my bundle still 300 KB?
Common culprits: (1) you're importing unused parts of libraries (tree-shake better), (2) you have duplicate dependencies (run npm ls to check), or (3) your charting library is bloated (consider a lighter alternative). Use the visualizer to identify the biggest offenders.
Can I defer everything with lazy loading?
No, above-the-fold content should load immediately. Only lazy-load content below the fold or in secondary tabs. If everything is lazy, the page feels empty on load.
How do I know if my optimizations worked?
Use Lighthouse (Chrome DevTools) or WebPageTest to compare before/after. Measure: LCP, INP, CLS, and time to interactive. A good SaaS dashboard has LCP <1.5s, INP <100ms, and CLS <0.05.