Skip to main content

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:

LibrarySizeReplacement
Lodash70 KBUse native Array methods or lodash-es
moment.js67 KBUse date-fns (13 KB) or day.js (2 KB)
react-icons200+ KBImport only needed icons
recharts (all)80 KBTree-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

MetricTargetCurrent
LCP (Largest Contentful Paint)<2.5sCheck with Lighthouse
INP (Interaction to Next Paint)<200msCheck with Chrome DevTools
CLS (Cumulative Layout Shift)<0.1Check with Lighthouse
Bundle size<100 KB gzippednpm run build and check dist/
Time to Interactive (TTI)<3sCheck 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.memo for 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.

Further Reading