← Back to Insights

SPA Architecture: Rendering Strategies for Scale

Metasphere Engineering 15 min read

You click a link in your single-page application and nothing happens. Two full seconds of nothing. The URL changed. The previous page disappeared. A spinner showed up. Your JavaScript bundle is downloading, parsing, and executing before the browser can paint a single pixel of the content you asked for. The user stares at a blank white screen. Lighthouse scores crater. Core Web Vitals fail. And the team starts debating whether to “just add SSR” as if that is a toggle you flip on a Tuesday afternoon.

The rendering strategy decision is the most consequential architecture choice in a modern SPA. Get it right and your app loads in under a second on 4G. Get it wrong and you are paying a performance tax on every single page load that no amount of optimization will fix. This is not something you can bundle-split your way out of after the fact.

The Four Rendering Strategies and When Each Actually Wins

The acronym soup (CSR, SSR, SSG, ISR) obscures a simple reality: each strategy trades off between build-time cost, server-time cost, and client-time cost. Stop debating them in the abstract. The right choice depends on how often your content changes and how personalized it is per request.

CSR makes sense for exactly one scenario: authenticated, highly interactive applications where the content is entirely user-specific. Dashboards, admin panels, real-time collaboration tools. The user is already committed to the app, so a loading state is tolerable. For anything that needs to be indexed by search engines or that users reach via external links, CSR is the wrong choice. Full stop.

SSR wins when content is personalized per request but needs to be crawlable and fast on first load. E-commerce product detail pages with user-specific pricing, search results, and social feeds all fit here. The cost is real server compute on every request. At scale, SSR origin servers become a scaling bottleneck unless you pair them with aggressive edge caching and streaming. Plan for this from the start.

SSG is the performance ceiling. Pre-built HTML served from a CDN. Zero server compute at request time. LCP under 600ms is routine. The constraint is content freshness: any change requires a rebuild and deploy. For content that changes less than hourly with under 50,000 pages, SSG is the right default. If you are shipping a blog on CSR, you are doing it wrong.

ISR bridges the gap. Pages are statically generated but regenerated in the background when stale. First visitor after the revalidation window gets the stale page while the new one builds. The practical limitation is that “stale” means different things to different stakeholders. A 60-second revalidation window is fine for a product catalog. It is unacceptable for a stock ticker. Know your content’s freshness requirements before you commit.

The Hydration Problem Is Worse Than You Think

Here is the dirty secret of SSR in React-based SPAs. The server sends fully rendered HTML. The browser paints it instantly. Beautiful. Then the page goes completely dead while React downloads and “hydrates,” re-attaching event listeners and reconciling the server HTML with the client-side component tree. Buttons do not respond. Forms do not submit. Links feel broken. The user sees a complete page but cannot interact with it. They click. Nothing. They click again. Still nothing. They leave.

This gap between First Contentful Paint and Time to Interactive is the hydration penalty. On a mid-range Android device over 4G, hydration alone takes 800ms to 2 seconds depending on component tree complexity. Your LCP looks great. Your INP (Interaction to Next Paint) tells the real story.

The hydration penalty scales with component tree depth and the number of interactive elements on the page. A marketing landing page with two buttons hydrates in 100ms. An e-commerce PDP with filters, variant selectors, reviews, and a cart widget takes 1.5 seconds. That 1.5 seconds is the number that matters for conversion, and it is the number your Lighthouse report hides if you only look at LCP.

Three approaches actually reduce the hydration penalty instead of just hiding it behind a pretty loading screen.

Selective hydration (React 18+) lets you wrap non-critical components in <Suspense> boundaries. React hydrates the critical interactive elements first and defers everything below the fold. The header search bar and add-to-cart button become interactive while the review section is still hydrating. This typically cuts perceived TTI by 40-60%.

Resumability is the approach Qwik pioneered. Instead of replaying component initialization on the client, the framework serializes the component state into the HTML. The client “resumes” from where the server left off without re-executing component code. The theoretical improvement is dramatic: near-zero hydration cost. The practical adoption is still early, and the ecosystem is a fraction of React’s. Watch this space, but do not bet your product on it yet.

React Server Components take a different angle entirely. Components that don’t need interactivity never ship JavaScript to the client. A product description component that just renders formatted text stays server-only. Its code, its dependencies, and its rendering cost are zero on the client. The interactive components still hydrate, but the total JavaScript that needs hydrating drops by 40-65% on content-heavy pages.

SPA rendering strategy comparison showing CSR, SSR, SSG, and streaming SSR timelinesFour rendering strategies compared on a timeline showing when first byte, first paint, and full interactivity occur for each approach, demonstrating the hydration penalty in SSR and the blank screen period in CSRRendering Strategy TimelinesTime to first meaningful content on mid-range mobile over 4GBlank / waitingVisible but deadInteractiveStreaming chunks0ms500ms1000ms1500ms2000ms+CSRClient-SideBlank screen - JS downloading + executingInteractiveLCP: ~1800msSSRServer-SideServer renderHydration penaltyInteractiveFCP: ~400msTTI: ~1100msSSGStatic GenHydrateInteractive - full speedFCP: ~80msTTI: ~350msStreamSSR StreamShellSel. hydrateInteractiveTTFB: ~80msTTI: ~600msStreaming SSR + selective hydration: best of SSR speed and SSG interactivityTTFB of SSG with personalization of SSR. The hydration penalty shrinks from 700ms to 200ms.

Streaming SSR: Why TTFB Matters More Than You Measured

Traditional SSR has a bottleneck that most teams do not measure until it hurts: the server must finish rendering the entire page before sending any HTML. If your page makes three API calls (user data at 50ms, product data at 200ms, and recommendations at 800ms), the browser waits 800ms before receiving a single byte. Your TTFB is hostage to the slowest data dependency on the page. One slow endpoint punishes every user on every request.

Streaming SSR (React 18’s renderToPipeableStream, Remix’s built-in loader streaming) changes the game. It sends HTML as each section resolves. The browser receives the shell and above-the-fold content while the recommendations endpoint is still processing. TTFB drops from “slowest API call” to “fastest API call,” typically cutting it by 40-70%.

The Remix loader pattern makes this ergonomic. Each route module exports a loader function, and Remix streams the response as data resolves. You don’t manage chunks manually. Next.js App Router achieves similar results with nested loading.tsx boundaries. In both cases, the framework handles the streaming plumbing.

One gotcha: streaming SSR and CDN caching interact poorly. Most CDNs buffer the full response before caching it, which negates the streaming benefit for cached pages. Cloudflare and Fastly support streaming responses, but you need to configure cache behavior per route. Pages that benefit most from streaming (personalized, uncacheable) are also the ones that need it most.

Bundle Splitting That Actually Works at Scale

The default webpack or Vite output for a production React app is a single JavaScript bundle. Everything in one file. Every route, every component, every utility function. On a mature application this easily exceeds 500KB gzipped. A user visiting the homepage downloads the entire admin panel. Think about how absurd that is.

Route-based code splitting is the first move and the highest-leverage one. Replace static imports with dynamic imports at route boundaries:

// Before: everything in one bundle
import Dashboard from './pages/Dashboard';
import Settings from './pages/Settings';

// After: each route is a separate chunk
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Settings = lazy(() => import('./pages/Settings'));

This alone typically cuts initial bundle size by 50-70%. The browser downloads only the JavaScript for the current route. Subsequent navigation loads additional chunks on demand.

Component-based splitting adds value for heavy widgets that appear on some routes but not others. A rich text editor (200KB+), a charting library (150KB+), or a map component (300KB+) should be lazily loaded even within a route. The threshold: if a component adds more than 20KB gzipped to a chunk and isn’t visible above the fold, split it.

Below 20KB the HTTP request overhead starts outweighing the bundle savings. Over-splitting is a real problem and we see teams fall into this trap regularly. Each chunk requires a separate HTTP request, DNS lookup (on HTTP/1.1), and parse cycle. Twenty 5KB chunks perform worse than one 100KB bundle. More is not always better.

The SPA Tax on Core Web Vitals

SPAs pay a structural penalty on three Core Web Vitals. You cannot optimize what you do not understand, so here is exactly where the tax comes from.

LCP (Largest Contentful Paint) suffers because CSR requires JavaScript execution before rendering. The render-blocking chain is: download HTML shell, download JS bundle, parse JS, execute JS, fetch data, render DOM. SSR or SSG removes the last three steps from the critical path. The typical improvement is 1.5-2.5 seconds on mobile.

INP (Interaction to Next Paint) takes hits from two SPA-specific patterns: hydration (discussed above) and client-side routing. When a user clicks a navigation link, the SPA must fetch data, update state, and re-render the destination page entirely in the main thread. A page transition that takes 300ms blocks all other interactions for those 300ms. startTransition in React 18 marks navigation as non-urgent, allowing the browser to handle user input during the render, but it’s not a default - you have to opt in.

CLS (Cumulative Layout Shift) is the sneaky one. SPAs that load content progressively (skeleton screens that get replaced with real content, images without explicit dimensions, dynamically injected ads) accumulate layout shifts with every state change. A skeleton-to-content transition that shifts surrounding elements by 20px might score 0.05 CLS by itself. Four such transitions on a page add up to 0.2, which fails the “good” threshold. Death by a thousand small shifts.

Teams building single-page applications that need to pass Core Web Vitals consistently should default to SSR or SSG for public-facing pages and reserve CSR for authenticated sections where search indexing and initial load performance matter less.

State Management: When Redux Hurts More Than It Helps

Redux became the default state management solution around 2016 and many teams never revisited that decision. The problem is not Redux itself. It is using a global client-side store as the primary data layer when most of that data is server state. This is the wrong pattern, and it is everywhere.

Server state (data fetched from APIs that belongs to the backend) makes up 80-90% of the state in a typical application. User profiles, product listings, search results, notifications. Redux stores all of this, then you manually add caching, invalidation, loading states, error states, and optimistic update logic. For every endpoint. The resulting boilerplate is substantial and the cache invalidation bugs are inevitable.

React Query (TanStack Query), SWR, and Apollo Client handle server state as a dedicated concern. They manage caching, background refetching, stale-while-revalidate, and optimistic updates out of the box. Switching from Redux to React Query for server state typically removes 30-50% of the Redux store and the associated action/reducer boilerplate. Teams are consistently surprised by how much code they can delete.

Redux still earns its place for genuinely client-side state: shopping cart contents, form wizard progress, UI preferences, and multi-step workflows where the state does not exist on any server. If your Redux store is mostly API response caches, the tool is not wrong. Your usage pattern is.

Edge Rendering: The Real Performance Envelope

Edge rendering with Cloudflare Workers, Vercel Edge Functions, or Deno Deploy puts SSR compute at CDN edge nodes instead of a regional origin server. The pitch is compelling: sub-50ms TTFB globally by running rendering code within 50ms of every user. Sounds incredible on a conference slide.

The reality is more constrained than the marketing suggests. Edge functions have hard limits: 128MB memory on Cloudflare Workers, 25ms CPU time on the free tier, no persistent connections to databases. You cannot run a full Next.js SSR pipeline with database queries at the edge without hitting these limits on complex pages. Teams learn this the hard way, usually in production.

What works at the edge: personalized response headers, A/B test assignment, geo-based content selection, authentication checks, and lightweight HTML assembly from cached fragments. What does not: pages that require multiple database joins, heavy computation, or large dependency trees.

The pattern that delivers real results is edge-origin hybrid rendering. The edge function handles personalization (inject the user’s name, select the right pricing tier, choose the A/B variant) and the origin server pre-renders the page content. The edge function assembles the final response from cached origin content plus personalized fragments. This keeps TTFB under 100ms globally while supporting complex rendering logic at the origin.

Solid cloud-native architecture decisions around edge vs. origin rendering prevent teams from hitting edge runtime limits in production after committing to the architecture.

Next.js vs Remix vs Vite: Choosing by Workload

The framework wars generate more heat than light. Stop arguing on Twitter and look at the actual performance envelopes. Each framework matches specific workloads.

Next.js App Router is the right choice for content-heavy sites that need ISR, middleware-based personalization, and a rich ecosystem. The Server Components integration is the most mature. The trade-off is build complexity. The App Router’s caching behavior has multiple layers (router cache, data cache, full route cache) that interact in non-obvious ways. Debugging why a page shows stale data requires understanding all three.

Remix excels at data-heavy interactive applications. Its loader/action model maps cleanly to CRUD operations. Streaming SSR is a first-class feature. Error boundaries at every route segment make error handling predictable. Remix’s philosophy of “use the platform” means fewer abstractions but also fewer escape hatches when you need behavior outside the happy path.

Vite + React Router (or just Vite with any framework) is the right choice when you need full control and your team can handle the wiring. No opinions about data fetching, caching, or rendering strategy. You choose each piece. This works well for teams with strong frontend infrastructure engineers. It is a trap for teams that will end up rebuilding half of Next.js’s features ad hoc over the next 18 months.

The honest answer: for most product teams, Next.js or Remix gets you to production faster. Vite is for teams that know exactly why they do not want a meta-framework and have the engineering bandwidth to maintain their own conventions. If you have to ask, you probably want Next.js.

Effective frontend UX engineering practice means choosing the framework whose opinions match your application’s actual needs rather than optimizing for flexibility you won’t use.

Making It Work: A Practical Rendering Architecture

The mistake we see over and over is picking one rendering strategy and applying it to every route. That is lazy architecture. A mature SPA uses multiple strategies on the same site.

Marketing pages: SSG. Pre-built at deploy time, served from CDN. LCP under 600ms.

Product pages: ISR with 5-minute revalidation. Fresh enough for catalog data. Static performance for most requests.

Search results: Streaming SSR. Personalized, uncacheable, but the shell loads in under 100ms while results stream in.

Dashboard: CSR behind authentication. No SEO requirement. The user is committed. Loading states are acceptable.

This per-route strategy is what Next.js App Router and Remix both support natively. The web application architecture decision is less about “SSR vs CSR” and more about “which rendering strategy fits each route’s content characteristics.”

The teams that consistently ship fast SPAs do not have a secret framework or a magic webpack config. They match rendering strategy to content type at the route level, split bundles at natural boundaries, and treat hydration cost as a first-class performance metric. If your SPA ships a 400KB bundle to render a blog post, the rendering strategy is the first conversation, not the last one.

The broader UI/UX engineering practice encompasses not just visual design but the rendering architecture decisions that determine whether users experience your design or stare at a spinner while your JavaScript boots up. Performance is a feature. Treat it like one.

Stop Shipping Blank Screens to Your Users

A bad rendering strategy taxes every Core Web Vital on every page load. Metasphere engineers SPA architectures that match rendering strategy to content type, cutting LCP by 40-60% and eliminating the hydration penalty that kills Time to Interactive.

Fix Your SPA Performance

Frequently Asked Questions

How much does client-side rendering hurt Largest Contentful Paint?

+

CSR adds 1.8-3.2 seconds to LCP on median mobile connections because the browser must download, parse, and execute JavaScript before rendering any meaningful content. SSR with streaming cuts this to 0.6-1.1 seconds by sending HTML chunks as they resolve. The gap widens on slower devices where JavaScript parse time alone can exceed 800ms.

When does Static Site Generation outperform SSR?

+

SSG wins when content changes less frequently than once per minute and the total page count stays under 50,000. Build times scale linearly with page count. At 100,000 pages a full rebuild takes 15-25 minutes on typical CI runners. ISR solves this by regenerating pages on demand, but introduces stale content windows equal to the revalidation interval.

What do React Server Components actually change about bundle size?

+

RSCs remove component JavaScript from the client bundle entirely for components that only render on the server. A typical dashboard page with heavy data-formatting libraries drops 40-65% of its client JS when those libraries stay server-side. The practical impact is a reduction in Total Blocking Time from 800ms+ to under 200ms on mid-range mobile devices.

Is route-based or component-based code splitting more effective?

+

Route-based splitting is the correct default. It reduces initial bundle size by 50-70% with minimal effort since bundlers like webpack and Vite handle it automatically with dynamic imports. Component-based splitting adds value only for heavy above-the-fold components like rich text editors or chart libraries that appear on some routes but not others. Over-splitting below 20KB chunks increases HTTP overhead and negates the benefit.

When does edge rendering actually improve performance over regional SSR?

+

Edge rendering reduces TTFB by 80-150ms for users more than 2000km from the nearest origin server. The benefit is most visible on personalized pages where CDN caching is ineffective. For static or lightly personalized content, a CDN with regional SSR origins achieves similar TTFB at lower complexity. Edge functions also have memory and execution time limits that make complex data fetching impractical.