Skip to content

SPA Performance — Optimizing Load Time, Runtime, and Perceived Performance

DodaTech Updated 2026-06-28 7 min read

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

  1. Not measuring before optimizing. Without performance metrics, you cannot identify bottlenecks. Always measure with Lighthouse, Performance API, or WebPageTest before optimizing.
  2. 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.
  3. Forgetting about the critical rendering path. JavaScript blocks rendering. Defer non-critical scripts, inline critical CSS, and use preload hints for important resources.
  4. 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.
  5. 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

  1. What is the difference between FCP, LCP, and TBT in Core Web Vitals?
  2. How does route-based code splitting improve initial load time?
  3. When should you use virtual scrolling instead of pagination?
  4. What is the critical rendering path and how does JavaScript affect it?
  5. 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

What is a good Lighthouse score for an SPA?

Aim for 90+ on Performance. SPAs typically score lower on the Performance metric due to JavaScript execution cost. Focus on real-user metrics (Core Web Vitals) rather than Lighthouse score alone.

Should I use a service worker for SPA performance?

Yes. A service worker can cache your app shell for instant loading on repeat visits and cache API responses for offline support. It is essential for progressive web apps.

How small should my initial bundle be?

Aim for under 200 KB (gzipped) for the initial bundle. Each additional 100 KB increases load time by roughly 0.5 seconds on average mobile connections.

Does tree shaking actually work?

Yes, but only with ES module imports. CommonJS imports cannot be tree-shaken. Use ES module syntax and configure your bundler properly for effective tree shaking.

What is the biggest performance win for most SPAs?

Route-based code splitting is the single highest-impact optimization for most SPAs. It reduces initial bundle size by 40-80 percent with minimal implementation effort.

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