COMP 305: Object-Oriented Software Design

University of San Diego, Fall 2025

Single Page Application (SPA)

A Single Page Application loads one HTML page and dynamically updates content using JavaScript, never reloading the page. Navigation, rendering, and state management all happen in the browser, creating an app-like experience. This is the quintessential "fat client" architecture.

Core Concept

Traditional websites load a new HTML page for every navigation. SPAs load HTML once, then JavaScript handles everything else:

Traditional Multi-Page App

  1. User clicks link
  2. Browser requests new page
  3. Server generates HTML
  4. Browser downloads HTML/CSS/JS
  5. Page renders
  6. Everything reloads

Time: 2-5 seconds

Single Page App

  1. User clicks link
  2. JavaScript intercepts click
  3. Update URL (History API)
  4. Fetch data if needed
  5. Update DOM with new content
  6. No page reload!

Time: 100-500ms

Basic SPA Implementation

Understanding the fundamentals: routing without page reloads.

// SINGLE PAGE APPLICATION (SPA)
// One HTML page, JavaScript handles everything

// index.html - The ONLY HTML file
<!DOCTYPE html>
<html>
<head>
    <title>My SPA</title>
</head>
<body>
    <!-- Empty container - JavaScript fills this -->
    <div id="root"></div>

    <!-- The entire app is in JavaScript -->
    <script src="/bundle.js"></script>
</body>
</html>

// app.js - The entire application
class SPARouter {
    constructor() {
        this.routes = {};
        this.currentRoute = null;

        // Handle browser back/forward
        window.addEventListener('popstate', () => {
            this.navigate(window.location.pathname, false);
        });

        // Intercept link clicks
        document.addEventListener('click', (e) => {
            if (e.target.tagName === 'A') {
                e.preventDefault();
                this.navigate(e.target.getAttribute('href'));
            }
        });
    }

    route(path, component) {
        this.routes[path] = component;
    }

    navigate(path, pushState = true) {
        const component = this.routes[path] || this.routes['/404'];

        // Update URL without page reload
        if (pushState) {
            history.pushState({}, '', path);
        }

        // Render component
        this.render(component);
    }

    render(component) {
        const root = document.getElementById('root');
        root.innerHTML = component();
    }
}

// Define routes
const router = new SPARouter();

router.route('/', () => `
    <h1>Home</h1>
    <p>Welcome to my SPA!</p>
    <a href="/about">About</a>
`);

router.route('/about', () => `
    <h1>About</h1>
    <p>This is a Single Page Application.</p>
    <a href="/">Home</a>
`);

router.route('/404', () => `
    <h1>404 Not Found</h1>
    <a href="/">Go Home</a>
`);

// Start the app
router.navigate(window.location.pathname, false);

/* KEY CHARACTERISTICS:
   - No page reloads (ever)
   - JavaScript handles ALL routing
   - Initial HTML is empty
   - App downloads as JavaScript bundle
   - Browser back/forward work via History API
*/

Modern Framework Example (React)

Production SPAs use frameworks like React, Vue, or Angular to manage complexity.

// REACT SPA EXAMPLE
// Modern framework approach

// App.jsx - Main component
import { BrowserRouter, Routes, Route, Link } from 'react-router-dom';
import { useState, useEffect } from 'react';

function App() {
    return (
        <BrowserRouter>
            <nav>
                <Link to="/">Home</Link>
                <Link to="/products">Products</Link>
                <Link to="/cart">Cart</Link>
            </nav>

            <Routes>
                <Route path="/" element={<HomePage />} />
                <Route path="/products" element={<ProductsPage />} />
                <Route path="/products/:id" element={<ProductDetail />} />
                <Route path="/cart" element={<CartPage />} />
                <Route path="*" element={<NotFound />} />
            </Routes>
        </BrowserRouter>
    );
}

function ProductsPage() {
    const [products, setProducts] = useState([]);
    const [loading, setLoading] = useState(true);

    useEffect(() => {
        // Fetch data when component mounts
        fetch('/api/products')
            .then(r => r.json())
            .then(data => {
                setProducts(data);
                setLoading(false);
            });
    }, []);

    if (loading) {
        return <div>Loading...</div>;
    }

    return (
        <div>
            <h1>Products</h1>
            {products.map(product => (
                <div key={product.id}>
                    <h2>{product.name}</h2>
                    <Link to={`/products/${product.id}`}>
                        View Details
                    </Link>
                </div>
            ))}
        </div>
    );
}

/* NAVIGATION FLOW:
   1. User clicks "Products" link
   2. React Router intercepts click (no page reload)
   3. URL changes to /products
   4. ProductsPage component mounts
   5. useEffect fetches data from API
   6. Component renders with data
   7. Total time: ~200ms (no page reload!)

   Compare to traditional:
   1. User clicks "Products" link
   2. Browser requests /products from server
   3. Server queries database
   4. Server renders HTML
   5. Browser downloads HTML, CSS, images
   6. Browser parses and renders
   7. Total time: 2-5 seconds (full page reload)
*/

State Management

Since the page never reloads, state persists in memory throughout the user's session.

// STATE MANAGEMENT IN SPAs
// The app never reloads, so state persists in memory

class AppState {
    constructor() {
        // Global application state
        this.user = null;
        this.cart = [];
        this.notifications = [];
        this.theme = 'light';

        // Listeners for state changes
        this.listeners = [];
    }

    // Update state and notify listeners
    setState(updates) {
        Object.assign(this, updates);
        this.notifyListeners();
    }

    subscribe(listener) {
        this.listeners.push(listener);
    }

    notifyListeners() {
        this.listeners.forEach(fn => fn(this));
    }

    // Actions
    async login(credentials) {
        const response = await fetch('/api/login', {
            method: 'POST',
            body: JSON.stringify(credentials)
        });

        const user = await response.json();
        this.setState({ user });

        // State persists across "page" changes
        // No need to refetch on every navigation
    }

    addToCart(product) {
        this.cart.push(product);
        this.setState({ cart: this.cart });

        // Cart state maintained in memory
        // Instant updates, no server round-trip
    }

    toggleTheme() {
        this.theme = this.theme === 'light' ? 'dark' : 'light';
        document.body.className = this.theme;
        this.setState({ theme: this.theme });

        // Theme persists as you navigate
    }
}

// Single global state instance
const appState = new AppState();

// Components subscribe to state changes
appState.subscribe((state) => {
    document.getElementById('cart-count').textContent = state.cart.length;
    document.getElementById('user-name').textContent = state.user?.name || 'Guest';
});

/* ADVANTAGES:
   - Instant state updates
   - No data refetching on navigation
   - Shared state across "pages"
   - Optimistic updates possible

   CHALLENGES:
   - State lost on page refresh
   - Must persist to localStorage/server
   - Memory leaks if not careful
   - State management complexity grows
*/

The SEO Challenge

SPAs start with an empty HTML shell, which is problematic for search engines and social media crawlers.

// SEO CHALLENGE WITH SPAs

// Problem: Initial HTML is empty
// =========================================

// index.html sent to search engines:
<!DOCTYPE html>
<html>
<head>
    <title>My App</title>
</head>
<body>
    <div id="root"></div>  <!-- EMPTY! -->
    <script src="/bundle.js"></script>
</body>
</html>

// Google sees: Nothing. No content. No links.
// Result: Your site doesn't get indexed properly.

// SOLUTION 1: Server-Side Rendering (SSR)
// =========================================

// Server renders initial HTML with content
app.get('/products/:id', async (req, res) => {
    const product = await db.products.findById(req.params.id);

    // Render React component to HTML string
    const html = ReactDOMServer.renderToString(
        <ProductDetail product={product} />
    );

    res.send(`
        <!DOCTYPE html>
        <html>
        <head>
            <title>${product.name}</title>
            <meta name="description" content="${product.description}">
        </head>
        <body>
            <div id="root">${html}</div>
            <script>
                // Hydrate: Make server-rendered HTML interactive
                window.__INITIAL_DATA__ = ${JSON.stringify(product)};
            </script>
            <script src="/bundle.js"></script>
        </body>
        </html>
    `);
});

// Now Google sees:
// ✓ Full product details
// ✓ Meta tags
// ✓ Content for indexing
// ✓ Links to crawl

// SOLUTION 2: Static Site Generation (SSG)
// =========================================

// Pre-render all pages at build time
// (Next.js, Gatsby, Astro)

export async function getStaticPaths() {
    const products = await fetchAllProducts();
    return products.map(p => ({
        params: { id: p.id }
    }));
}

export async function getStaticProps({ params }) {
    const product = await fetchProduct(params.id);
    return { props: { product } };
}

// Generates static HTML files:
// /products/1.html
// /products/2.html
// etc.

// SOLUTION 3: Pre-rendering Service
// =========================================

// Use service like Prerender.io
// Detects search engine bots
// Serves pre-rendered HTML to bots
// Serves SPA to users

if (userAgent.includes('googlebot')) {
    res.send(prerenderedHTML);
} else {
    res.send(spaHTML);
}

SEO Solutions

Approach How it Works Best For
SSR Render HTML on server per request Dynamic content, personalization
SSG Pre-render at build time Static content, blogs
Prerendering Serve static HTML to bots only Existing SPAs, quick fix

Performance Optimization

SPAs can become slow if not optimized properly.

// PERFORMANCE OPTIMIZATION FOR SPAs

// Problem 1: Large bundle size
// =========================================

// BAD: Bundle includes entire app
// bundle.js: 2MB
// User must download everything before anything works

// GOOD: Code splitting
import { lazy, Suspense } from 'react';

const ProductsPage = lazy(() => import('./ProductsPage'));
const CartPage = lazy(() => import('./CartPage'));
const AdminPanel = lazy(() => import('./AdminPanel'));

function App() {
    return (
        <Suspense fallback={<Loading />}>
            <Routes>
                <Route path="/products" element={<ProductsPage />} />
                <Route path="/cart" element={<CartPage />} />
                <Route path="/admin" element={<AdminPanel />} />
            </Routes>
        </Suspense>
    );
}

// Now:
// - Initial bundle: 200KB (just what's needed)
// - ProductsPage: Loads when user navigates there
// - AdminPanel: Only loads for admins

// Problem 2: Slow initial load
// =========================================

// Loading sequence:
// 1. Download HTML (1KB) - 50ms
// 2. Download bundle.js (500KB) - 1000ms
// 3. Parse JavaScript - 200ms
// 4. Execute app - 50ms
// 5. Fetch data from API - 300ms
// 6. Render content - 50ms
// Total: 1650ms until user sees content

// OPTIMIZATION: Parallel loading
// While JavaScript downloads, also fetch data

// index.html
<script>
    // Start fetching data immediately
    const dataPromise = fetch('/api/initial-data').then(r => r.json());

    // Make it available to app
    window.__DATA_PROMISE__ = dataPromise;
</script>
<script src="/bundle.js"></script>

// app.js
function App() {
    const [data, setData] = useState(null);

    useEffect(() => {
        // Data might already be fetched!
        window.__DATA_PROMISE__.then(setData);
    }, []);
}

// New sequence:
// 1. Download HTML (1KB) - 50ms
// 2. Start data fetch + Start bundle download (parallel)
// 3. Bundle downloads - 1000ms
// 4. Parse JavaScript - 200ms
// 5. Execute app - 50ms
// 6. Data already available! - 0ms
// 7. Render content - 50ms
// Total: 1350ms (300ms faster!)

// Problem 3: Re-fetching data
// =========================================

// Without caching, every navigation re-fetches
const ProductsPage = () => {
    useEffect(() => {
        fetch('/api/products'); // Fetch every time!
    }, []);
};

// WITH caching
const cache = new Map();

async function fetchWithCache(url) {
    if (cache.has(url)) {
        return cache.get(url);
    }

    const response = await fetch(url);
    const data = await response.json();
    cache.set(url, data);

    // Optional: Expire cache after 5 minutes
    setTimeout(() => cache.delete(url), 5 * 60 * 1000);

    return data;
}

const ProductsPage = () => {
    useEffect(() => {
        fetchWithCache('/api/products'); // Instant on repeat visits!
    }, []);
};

Performance Best Practices

Offline Capabilities

SPAs can leverage Service Workers to work offline, a capability impossible for traditional websites without JavaScript, but quite possible if JavaScript is allowed.

// OFFLINE CAPABILITIES
// SPAs can work offline using Service Workers

// service-worker.js
const CACHE_NAME = 'my-spa-v1';
const urlsToCache = [
    '/',
    '/bundle.js',
    '/styles.css',
    '/offline.html'
];

// Install: Cache critical resources
self.addEventListener('install', (event) => {
    event.waitUntil(
        caches.open(CACHE_NAME)
            .then(cache => cache.addAll(urlsToCache))
    );
});

// Fetch: Serve from cache, fallback to network
self.addEventListener('fetch', (event) => {
    event.respondWith(
        caches.match(event.request)
            .then(response => {
                // Cache hit - return cached response
                if (response) {
                    return response;
                }

                // Cache miss - fetch from network
                return fetch(event.request)
                    .then(response => {
                        // Cache successful responses
                        if (response.ok) {
                            const responseClone = response.clone();
                            caches.open(CACHE_NAME)
                                .then(cache => {
                                    cache.put(event.request, responseClone);
                                });
                        }
                        return response;
                    })
                    .catch(() => {
                        // Network failed - show offline page
                        return caches.match('/offline.html');
                    });
            })
    );
});

// Register service worker
if ('serviceWorker' in navigator) {
    navigator.serviceWorker.register('/service-worker.js');
}

// Now your SPA works offline!
// - App shell loads from cache
// - Previously viewed pages available
// - Graceful fallback for network failures

// Offline-first state management
class OfflineStore {
    constructor() {
        this.online = navigator.onLine;
        this.queue = [];

        window.addEventListener('online', () => {
            this.online = true;
            this.syncQueue();
        });

        window.addEventListener('offline', () => {
            this.online = false;
        });
    }

    async save(data) {
        if (this.online) {
            // Online: Save to server
            await fetch('/api/save', {
                method: 'POST',
                body: JSON.stringify(data)
            });
        } else {
            // Offline: Queue for later
            this.queue.push(data);
            // Save to IndexedDB
            await this.saveLocally(data);
        }
    }

    async syncQueue() {
        // Back online - sync queued operations
        for (const data of this.queue) {
            await fetch('/api/save', {
                method: 'POST',
                body: JSON.stringify(data)
            });
        }
        this.queue = [];
    }
}

Benefits

Drawbacks

When to Use SPAs

When NOT to Use SPAs

Popular SPA Frameworks

Framework Approach Best For
React Component-based, virtual DOM Large apps, ecosystem
Vue Progressive framework Easy learning curve
Angular Full framework, TypeScript Enterprise apps
Svelte Compiler, no runtime Performance-critical

Related Patterns