Rendering

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.

User
Browser
Network
Server
JS Engine
Compositor
GPU
Display
  1. 01

    URL entry / link click

    01 User URL entry / link click

    The user types a URL or clicks a link on an existing page. Everything downstream starts here.

  2. 02

    Issue HTTP request

    02 Browser Issue HTTP request

    The browser detects the navigation and prepares the HTTP request. Any Cookie / Cache / Service Worker interception is decided here.

  3. 03

    DNS / TCP / TLS

    03 Network 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.

  4. 04

    Return empty HTML shell

    04 Server 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.

  5. 05

    Parse HTML → DOM (root only)

    05 Browser 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.

  6. 06

    Parse CSS → CSSOM

    06 Browser 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.

  7. 07

    Style / Layout / Paint-record

    07 Browser 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.

  8. 08

    Raster + layer composite (empty tiles)

    08 Compositor 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.

  9. 09

    GPU commands → frame buffer

    09 GPU 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. 10

    First frame shown on screen

    10 Display 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.

  11. FCP reached — but the screen is empty
  12. 11

    Request JS bundle

    11 Browser 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.

  13. 12

    JS bundle transfer

    12 Network 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.

  14. 13

    JS parse / compile

    13 JS Engine 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.

  15. 14

    Framework boots → build VDOM

    14 JS Engine 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.

  16. 15

    DOM commit (VDOM → real DOM)

    15 JS Engine 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.

  17. 16

    Style recalc + Layout (with content)

    16 Browser 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.

  18. 17

    Re-raster + recomposite

    17 Compositor Re-raster + recomposite

    The compositor re-rasterizes tiles and rebuilds the layer tree. Chrome's RenderingNG optimizes by re-rasterizing only changed tiles.

  19. 18

    Update frame buffer

    18 GPU Update frame buffer

    The GPU runs the compositor commands to produce the new frame. When only transform / opacity change, layout / paint-record can be skipped and GPU composition alone suffices — that's why these are fast.

  20. 19

    Content appears on screen

    19 Display 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).

  21. LCP lands near here — real content is visible
  22. 20

    Attach listeners → TTI

    20 JS Engine 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.

  23. TTI reached — the page is finally usable
  24. 21

    Afterwards: client-side navigation

    21 User 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.

  25. 22

    VDOM diff → DOM patch (repeat)

    22 JS Engine 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?
Because the initial HTML contains no content. Googlebot does execute JavaScript but defers it to a rendering queue, causing delays or dropped pages. Other crawlers (Bing / Twitter cards / OGP bots) often don't execute JavaScript at all and see only an empty HTML shell. Fix with (a) pre-rendering to static HTML at build time, (b) migrating to SSR, or (c) inlining critical content into the initial HTML. Conversely, modern setups like Next.js / Nuxt / SvelteKit that keep the SPA developer experience while pairing with SSR / SSG can match the SEO strength of a traditional MPA. The accurate framing: it's not 'SPAs are weak for SEO' but rather 'CSR-only setups with empty initial HTML are weak for SEO'.
Q. Why is my FCP fast but LCP slow?
Because step 04 (painting the blank shell) counts as FCP even though there's no real content. The actual content paints at step 07 or later, and LCP (Largest Contentful Paint) measures from there. Gaps of 2–3 seconds between FCP and LCP are common. Fix: shrink the critical path (smaller bundles, code splitting, dynamic imports) or switch to SSR so step 04 already has content.
Q. Why does loading a deep URL directly or hitting refresh return 404?
Because the server has no /about HTML file — only index.html exists. Since SPA routing is handled in JavaScript, you need a 'SPA fallback' that always serves index.html for unknown routes. Vercel / Netlify handle it automatically; for nginx use 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?
A common cause is not listening to 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?
No — a pure SPA does not hydrate. Hydration is the act of reusing server-rendered HTML, which only applies to SSR / SSG. A SPA receives empty HTML and JavaScript draws everything from scratch, so there's no hydration step. However, if you ship a Next.js / Nuxt SSR app exported as static, hydration does run — you'll see hydrateRoot in logs.
Q. I heard SPAs break accessibility. Why?
Browsers normally handle focus management and title announcements on page navigation, but SPAs don't actually 'navigate', so all of this becomes manual. You need to (a) move focus to the new page's <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.

Consider moving away

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.

Consider moving away

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.

Stay with SPA

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.

Stay with SPA

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).