SPA (Single Page Application)
An SPA (Single-page application) is a web app implementation that loads only a single web document, and then updates the body content of that single document via JavaScript APIs such as Fetch when dif
From MDN Web Docs
An SPA (Single-page application) is a web app implementation that loads only a single web document, and then updates the body content of that single document via JavaScript APIs such as Fetch when different content is to be shown.
This therefore allows users to use websites without loading whole new pages from the server, which can result in performance gains and a more dynamic experience, with some tradeoff disadvantages such as SEO, more effort required to maintain state, implement navigation, and do meaningful performance monitoring.
Metrics Primer: FCP / LCP / TTI
The swim-lane diagram below marks FCP / LCP / TTI milestones. Knowing what each metric actually measures upfront makes the diagram much easier to read.
- FCP First Contentful Paint — first visual content painted
- The moment text, images, SVG, or non-empty canvas is first painted on the browser viewport. A background-color-only screen doesn't count. A Core Web Vitals secondary metric — under 1.8 s is considered good.
- LCP Largest Contentful Paint — largest content painted
- When the largest content element (hero image, big heading, etc.) is painted. A proxy for 'meaningful first paint' and a primary Core Web Vitals metric. Under 2.5 s is good; over 4 s needs work.
- TTI Time to Interactive — when interactivity starts
- The moment the page is fully interactive — all event listeners attached and the main thread free of Long Tasks for 5 seconds. Dropped from the Core Web Vitals primary set (replaced by INP) in 2024, but still important as the 'feels usable' moment for real users.
Rendering Lifecycle
The 22 phases from URL entry to interactive, split across eight lanes. After React commits the VDOM inside the JS Engine, the real rendering pipeline flows through Browser (Style/Layout) → Compositor (Raster) → GPU (frame buffer) → Display (vsync-synced pixels). The physical differences between FCP, LCP, and TTI become visible here.
-
01 URL entry / link click
The user types a URL or clicks a link on an existing page. Everything downstream starts here.
-
02 Issue HTTP request
The browser detects the navigation and prepares the HTTP request. Any Cookie / Cache / Service Worker interception is decided here.
-
03 DNS / TCP / TLS
DNS resolution → TCP handshake → TLS handshake run in sequence at the network layer. A non-trivial window in modern web performance — HTTP/3 / 0-RTT can shorten it.
-
04 Return empty HTML shell
The server (usually static hosting) returns a nearly empty HTML document — an app root element like
<div id="app"></div>plus<script>references to the JavaScript bundle. -
05 Parse HTML → DOM (root only)
The browser parses the HTML into a DOM, but there's only the app root element, so the DOM is nearly empty.
-
06 Parse CSS → CSSOM
Linked CSS is parsed into a CSSOM. Combined with the DOM it forms the render tree — though there's no real content yet.
-
07 Style / Layout / Paint-record
The main thread runs style calculation → layout → paint-record (produces a list of drawing commands). With only the empty root element, the generated commands are minimal. Pixels have not yet been rasterized.
-
08 Raster + layer composite (empty tiles)
The compositor thread (separate from the main thread in Chrome) breaks the paint records into tiles and rasterizes them, then assembles composite commands from the layer tree.
-
09 GPU commands → frame buffer
The GPU process (a separate process in Chrome) executes the composite commands and writes into the frame buffer. Hardware acceleration handles tens of thousands of tiles in milliseconds.
-
10 First frame shown on screen
In sync with the monitor refresh (vsync — every 16.67 ms on a 60 Hz display), the frame buffer is scanned out to the screen. This is when pixels finally reach the user's eyes. For a SPA, it's a blank screen. Strictly, Core Web Vitals' FCP lands here, not at paint-record completion.
- FCP reached — but the screen is empty
-
11 Request JS bundle
Per the
<script>tags found during HTML parsing, the browser starts fetching the JS bundle in parallel. What arrives is still a file — no code has run yet. -
12 JS bundle transfer
The bundle (React / Vue / Angular plus application code) travels over the network. On 4G mobile, anything over 500 KB tends to stall for seconds.
-
13 JS parse / compile
The JS engine (V8 / SpiderMonkey) parses the source and compiles it to bytecode. On mid-range phones ~1 MB roughly equals ~200 ms of parse time.
-
14 Framework boots → build VDOM
React's createRoot / render() (or equivalents) runs and builds the virtual DOM by executing component functions. The real DOM hasn't changed yet.
-
15 DOM commit (VDOM → real DOM)
The framework commits the virtual DOM into the real DOM by calling DOM APIs (createElement / appendChild) from the JS Engine into the browser. This triggers the next rendering cycle.
-
16 Style recalc + Layout (with content)
With a modified DOM, the browser re-runs style calculation → layout → paint-record. This time there's real content, so the paint commands are substantial.
-
17 Re-raster + recomposite
The compositor re-rasterizes tiles and rebuilds the layer tree. Chrome's RenderingNG optimizes by re-rasterizing only changed tiles.
-
18 Update frame buffer
The GPU runs the compositor commands to produce the new frame. When only
transform/opacitychange, layout / paint-record can be skipped and GPU composition alone suffices — that's why these are fast. -
19 Content appears on screen
In sync with vsync, the frame is scanned out. The user sees real content for the first time. Per web.dev's Web Vitals definitions, LCP (Largest Contentful Paint) measures "the render time of the largest image, text block, or video visible in the viewport". A SPA returns a near-empty shell HTML first and paints real content only after its JS bundle executes, so structurally a time gap forms between FCP (first paint) and LCP (real content paint).
- LCP lands near here — real content is visible
-
20 Attach listeners → TTI
The framework attaches click / input listeners on each component and initializes useState / stores. The main thread becomes idle and ready to react to clicks. Time to Interactive (TTI) lands here.
- TTI reached — the page is finally usable
-
21 Afterwards: client-side navigation
Link clicks rewrite the URL via the History API (pushState), and JavaScript renders the matching component. Subsequent network trips are only for API data (JSON). This is the source of SPA's 'feels fast' impression.
-
22 VDOM diff → DOM patch (repeat)
On every state change, the framework diffs the old and new VDOM and patches only the changed nodes into the real DOM. The Browser → Compositor → GPU → Display pipeline runs again, but with zero network round-trips. This is SPA's biggest strength.
Frequently Asked Questions
Real sticking points for people learning SPA for the first time — and what causes each one at the root.
Q. Why is a CSR-only SPA considered weak for SEO?
Q. Why is my FCP fast but LCP slow?
Q. Why does loading a deep URL directly or hitting refresh return 404?
try_files $uri $uri/ /index.html;; for Apache use FallbackResource /index.html.Q. Why does the back button break, or why does scroll position jump around?
popstate. The History API comes as a trio: pushState, replaceState, and the popstate event. popstate fires on back/forward navigation, and without handling it the UI doesn't update. For scroll position, set history.scrollRestoration = 'manual' and let the routing library manage restoration explicitly. React Router / Vue Router / SvelteKit handle this internally.Q. Does 'hydration' apply to SPAs?
hydrateRoot in logs.Q. I heard SPAs break accessibility. Why?
<h1> or <main>, (b) update document.title, and (c) announce changes via an aria-live="polite" region. React Router v6+ and Remix provide helpers for this.Design Signals
Symptoms that suggest moving away from SPA — and cases where staying with SPA is the right call.
SEO traffic is becoming a primary channel
If marketing starts adding landing pages and blog sections, consider a hybrid setup where those routes become SSR or SSG. Next.js / Nuxt let you change rendering mode per page.
Initial FCP / LCP consistently above 2.5 s
LCP enters the Yellow / Red zone in Core Web Vitals. If bundle optimization has hit its limits, fall back to SSR / SSG for at least the critical above-the-fold content. Especially important if mobile networks are a target audience.
Post-login dashboards / admin UIs are the main product
No SEO, no crawlers, plenty of state and interactions. This is SPA's sweet spot — there's little reason to pay the SSR tax for a logged-in dashboard.
Sharing a backend with an existing API server
If mobile apps or other clients already share the API, keeping the frontend as a pure API consumer is simpler. SSR would add a 'frontend server + API server' two-tier architecture, complicating deploys and monitoring.
Common Pitfalls
Runaway bundle size
Casual library additions swell the bundle to 1–2 MB, stalling mobile users for seconds. Fix: code splitting (React.lazy / dynamic import), Bundle Analyzer to visualize dependencies, tree-shaking, and swapping heavy libs for lighter equivalents (e.g., moment → dayjs).
Accumulating memory leaks
Long-lived SPA sessions accumulate uncleaned event listeners, timers, and WebSockets until memory reaches 500 MB or 1 GB. Fix: always clean up in useEffect teardown, abort in-flight fetches with AbortController, and profile periodically with Chrome DevTools' Memory tab.
Broken OGP / social share previews
Rewriting <meta property="og:title"> via JavaScript doesn't help — social crawlers don't execute JS and only read meta tags from the initial HTML. For dynamic meta tags, embed them via SSR / pre-rendering, or prepare static HTML per page.
Storing auth tokens in the wrong place
SPAs must store auth on the browser, and it's common (but risky) to put JWTs in localStorage — a single XSS leaks them. Fix: use HTTP-only cookies with SameSite=Strict so JavaScript can't read them. SPAs can still adopt this pattern (the login API sets the cookie).