SPA Performance — Optimizing Load Time, Runtime, and Perceived Performance
In this tutorial, you will learn about SPA Performance. We cover key concepts, practical examples, and best practices to help you master this topic.
SPA performance optimization covers bundle splitting, lazy loading, caching strategies, render optimization, Core Web Vitals improvement, and perceived performance techniques for faster single-page applications.
What You'll Learn
By the end of this tutorial, you will understand how to measure and optimize SPA performance including bundle size reduction, code splitting, lazy loading routes and components, image optimization, caching strategies, render optimization with virtualization, and improving Core Web Vitals scores.
Why It Matters
Performance directly impacts user experience, conversion rates, and SEO. A 1-second delay in page load reduces conversions by 7 percent. SPAs are particularly vulnerable because they must download and execute JavaScript before displaying any content. Poor performance drives users away before they see your app.
Real-World Use
A SaaS analytics SPA reduced initial bundle size from 2.3 MB to 380 KB by implementing route-based code splitting, lazy loading the charting library, and optimizing images. Core Web Vitals improved from poor to good across all metrics. Bounce rate dropped 22 percent and trial signups increased 35 percent.
SPA Performance Optimization Levers
┌──────────────────────────────────────────────────────────┐
│ SPA Performance Optimization │
├──────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────┐ ┌──────────────┐ ┌────────────────┐ │
│ │ Bundle │ │ Runtime │ │ Perceived │ │
│ │ Optimization │ │ Optimization │ │ Performance │ │
│ ├─────────────┤ ├──────────────┤ ├────────────────┤ │
│ │ Code │ │ Virtual │ │ Skeleton │ │
│ │ splitting │ │ scrolling │ │ screens │ │
│ │ Tree │ │ Debouncing │ │ Optimistic │ │
│ │ shaking │ │ Memoization │ │ UI │ │
│ │ Dynamic │ │ Web Workers │ │ Immediate │ │
│ │ imports │ │ RAF │ │ feedback │ │
│ │ Compression │ │ throttling │ │ Transitions │ │
│ └─────────────┘ └──────────────┘ └────────────────┘ │
│ │
│ Impact: FCP, TTI, Impact: INP, Impact: │
│ TBT, LCP smoothness user trust │
└──────────────────────────────────────────────────────────┘
Think of SPA performance like a restaurant kitchen. The bundle is your ingredient pantry — a smaller, organized pantry means the chef finds things faster (faster load time). Runtime optimization is the cooking Process — well-trained chefs use efficient techniques (smooth interactions). Perceived performance is the appetizer — guests feel served even before the main course arrives.
Measuring Performance with the Performance API
// Measure Core Web Vitals programmatically
function measurePerformance() {
const metrics = {};
// First Contentful Paint
const paintEntries = performance.getEntriesByType('paint');
paintEntries.forEach(entry => {
metrics[entry.name] = entry.startTime.toFixed(2) + 'ms';
});
// Largest Contentful Paint
const observer = new PerformanceObserver((list) => {
const entries = list.getEntries();
const lastEntry = entries[entries.length - 1];
metrics['LCP'] = lastEntry.startTime.toFixed(2) + 'ms';
console.log('LCP:', metrics['LCP']);
});
observer.observe({ type: 'largest-contentful-paint', buffered: true });
// First Input Delay (approximation)
const fidObserver = new PerformanceObserver((list) => {
list.getEntries().forEach(entry => {
metrics['FID'] = entry.processingStart - entry.startTime + 'ms';
console.log('FID:', metrics['FID']);
});
});
fidObserver.observe({ type: 'first-input', buffered: true });
// Cumulative Layout Shift
const clsObserver = new PerformanceObserver((list) => {
let clsValue = 0;
list.getEntries().forEach(entry => {
if (!entry.hadRecentInput) {
clsValue += entry.value;
}
});
metrics['CLS'] = clsValue.toFixed(3);
console.log('CLS:', metrics['CLS']);
});
clsObserver.observe({ type: 'layout-shift', buffered: true });
// Resource timing
const resources = performance.getEntriesByType('resource');
resources.forEach(resource => {
if (resource.initiatorType === 'script') {
console.log(`Script: ${resource.name} — ${resource.duration.toFixed(2)}ms`);
}
});
// Bundle size check
const totalScriptSize = resources
.filter(r => r.initiatorType === 'script')
.reduce((sum, r) => sum + r.transferSize, 0);
console.log(`Total JS size: ${(totalScriptSize / 1024).toFixed(1)} KB`);
}
// Expected output:
// LCP: 2450.32ms
// FID: 12ms
// CLS: 0.045
// Script: /static/js/main.abc123.js — 1234.56ms
// Total JS size: 456.7 KB
Route-Based Code Splitting
// React — lazy load routes
import { lazy, Suspense, Component } from 'react';
import { Routes, Route } from 'react-router-dom';
// These components are loaded on demand
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Products = lazy(() => import('./pages/Products'));
const Analytics = lazy(() => import('./pages/Analytics'));
const Settings = lazy(() => import('./pages/Settings'));
// Preload on hover for instant navigation
function preloadOnHover(preloadFn) {
let preloaded = false;
return {
onMouseEnter: () => {
if (!preloaded) {
preloaded = true;
preloadFn();
}
}
};
}
function AppRoutes() {
return (
<Suspense fallback={
<div className="skeleton-screen">
<div className="skeleton-header" />
<div className="skeleton-content" />
</div>
}>
<Routes>
<Route path="/" element={<Dashboard />} />
<Route
path="/products"
element={
<div {...preloadOnHover(() => import('./pages/Analytics'))}>
<Products />
</div>
}
/>
<Route path="/analytics" element={<Analytics />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</Suspense>
);
}
Virtual Scrolling for Large Lists
import { useState, useRef, useCallback, useEffect } from 'react';
// Virtual scroll — only renders visible rows
function VirtualList({ items, itemHeight, containerHeight }) {
const [scrollTop, setScrollTop] = useState(0);
const containerRef = useRef(null);
const startIndex = Math.floor(scrollTop / itemHeight);
const visibleCount = Math.ceil(containerHeight / itemHeight);
const endIndex = Math.min(startIndex + visibleCount + 2, items.length);
const visibleItems = items.slice(startIndex, endIndex);
const offsetY = startIndex * itemHeight;
const handleScroll = useCallback(() => {
requestAnimationFrame(() => {
setScrollTop(containerRef.current.scrollTop);
});
}, []);
return (
<div
ref={containerRef}
onScroll={handleScroll}
style={{
height: containerHeight,
overflow: 'auto',
position: 'relative'
}}
>
<div style={{
height: items.length * itemHeight,
position: 'relative'
}}>
<div style={{
position: 'absolute',
top: offsetY,
left: 0,
right: 0
}}>
{visibleItems.map((item, index) => (
<div
key={item.id}
style={{
height: itemHeight,
display: 'flex',
alignItems: 'center',
padding: '0 16px'
}}
>
{item.name}
</div>
))}
</div>
</div>
</div>
);
}
// Performance comparison:
// 10,000 items, each 40px height
// Without virtual scroll: 10,000 DOM nodes
// With virtual scroll: ~20 DOM nodes (visible + buffer)
// Memory reduction: ~98%
// Render time per scroll: <16ms (60fps)
Common Mistakes
- Not measuring before optimizing. Without performance metrics, you cannot identify bottlenecks. Always measure with Lighthouse, Performance API, or WebPageTest before optimizing.
- Large initial bundle. Sending all JavaScript for the entire app on the first load is the #1 SPA performance mistake. Implement route-based code splitting from the start.
- Forgetting about the critical rendering path. JavaScript blocks rendering. Defer non-critical scripts, inline critical CSS, and use preload hints for important resources.
- Over-optimizing prematurely. Not every app needs virtual scrolling, Web Workers, or service worker caching. Start with the basics: bundle size, image optimization, and lazy loading.
- Ignoring mobile performance. Mobile devices have slower CPUs and less memory. Test on real mobile devices, not just desktop Chrome DevToolsk "DevTools" >}} emulation.
Practice Questions
- What is the difference between FCP, LCP, and TBT in Core Web Vitals?
- How does route-based code splitting improve initial load time?
- When should you use virtual scrolling instead of pagination?
- What is the critical rendering path and how does JavaScript affect it?
- How do skeleton screens improve perceived performance?
Challenge: Audit an existing SPA with Lighthouse and WebPageTest. Identify the top 3 performance bottlenecks. Implement fixes: route-based code splitting for the 3 largest pages, lazy load the heaviest third-party library, add skeleton screens for async content, optimize the 5 largest images to WebP, and implement virtual scrolling for a data table with 5,000 rows.
FAQ
Mini Project
Optimize a blog SPA with 20 pages and 50 images: implement route-based code splitting for all page routes, lazy load images with native loading=lazy, convert all images to WebP, add skeleton screens for content loading, implement virtual scrolling for the blog archive list, add a service worker for app shell caching, and verify improvements with Lighthouse before/after.
What's Next
You understand SPA performance. Now explore SPA deployment to deploy your optimized application to production.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro