SPA Shell Caching vs Full SSR

After reading this you will be able to choose, per route, between serving a cached client-rendered shell that hydrates and fetches its own data, and rendering the full route on the server for every request — weighing the two against Time to First Byte, largest paint, crawlability, and running cost.

← Back to SPA vs MPA Tradeoffs

Prerequisites

Core Concept

The two strategies answer the same question — “what bytes come back on the first request for a route?” — in opposite ways. Shell caching returns an identical, content-free HTML skeleton for every route: the same file, cacheable forever, served from the nearest edge node in single-digit milliseconds. The browser then boots the JavaScript, reads the URL, and fetches the route’s data client-side, painting real content only after that round trip resolves. Full server-side rendering does the opposite: it runs the route’s data fetch and component tree on the server for this specific request, then streams a document that already contains the finished content, so the first bytes are meaningful but they cannot arrive until the server has done the work.

That trade sits on a single fulcrum. Shell caching wins Time to First Byte outright — a cached static file has essentially no server think-time — but pays for it with a later Largest Contentful Paint, because the meaningful pixels wait on hydration plus a data fetch. Full SSR pays a higher, request-dependent TTFB because the server must render before it can respond, but reaches meaningful content and LCP sooner and hands crawlers complete HTML with no execution required. Shell caching is cheap and trivially scalable (you are serving one static asset); SSR costs compute on every request and complicates personalisation only in that the personalised markup can no longer be cached whole. Neither is globally correct — the answer is per route, and often the same site uses both.

Request flow for a cached app shell versus full server-side rendering The cached shell returns instantly from the edge then fetches data client-side, while server-side rendering does the data work first and returns finished HTML with a higher time to first byte. Cached shell edge cache fast TTFB hydrate + fetch later LCP content paints Full SSR server renders higher TTFB full HTML sent crawler-ready early LCP
The cached shell trades an early TTFB for a later content paint; full SSR does the data work up front, delaying the first byte but painting meaningful content sooner.

Comparison

Signal Cached app shell Full SSR
TTFB Near-instant (static edge hit) Higher, varies with render + data time
FCP / LCP FCP early, LCP waits on hydrate + fetch Both early — content ships in the HTML
SEO / crawl Needs JS execution for content Complete HTML, no execution needed
Infra cost Minimal — one static asset, cache forever Compute per request; scales with traffic
Personalisation Fetched client-side, shell stays cacheable In markup, but breaks whole-response caching
Data freshness As fresh as the client fetch As fresh as render time
Best fit Interaction-heavy, authenticated, repeat-visit apps Public, content-dense, first-paint-critical routes

Implementation

The two flows are easiest to compare as request handlers. The shell handler returns a cached, content-free document immediately and defers all data work to a client fetch; the SSR handler awaits the data and renders the finished markup before responding. Note where the await sits — that placement is the TTFB difference.

// TypeScript 5.x — framework-agnostic request-flow sketch, no runtime dependencies

interface RouteRequest {
  path: string;
  userId: string | null;
}

interface Rendered {
  html: string;
  cacheControl: string;
}

// --- Flow A: cached app shell — no per-request server work, fetch on client. ---
export function serveShell(_req: RouteRequest, shell: string): Rendered {
  // Identical for every route and user, so it can live in a long-lived cache.
  // The browser hydrates this, reads location.pathname, then fetches data.
  return {
    html: shell, // content-free skeleton; see App Shell Caching for delivery
    cacheControl: "public, max-age=31536000, immutable",
  };
}

// The client half runs after the shell paints — this is what defers LCP.
export async function hydrateAndFetch(path: string): Promise<void> {
  const data = await fetch(`/api/route-data?path=${encodeURIComponent(path)}`).then((r) => r.json());
  renderInto(document.getElementById("app")!, data); // content paints only now
}

// --- Flow B: full SSR — do the data + render work before the first byte. ---
export async function serveSSR(req: RouteRequest, render: (d: unknown) => string): Promise<Rendered> {
  // The await here is the whole trade: TTFB cannot beat this fetch + render.
  const data = await loadRouteData(req.path, req.userId);
  const html = render(data); // finished, crawlable markup in the response body

  // Personalised markup must NOT be cached whole; vary or bypass the shared cache.
  const cacheControl = req.userId ? "private, no-store" : "public, max-age=60";
  return { html, cacheControl };
}

// Declarations elided for brevity.
declare function renderInto(el: HTMLElement, data: unknown): void;
declare function loadRouteData(path: string, userId: string | null): Promise<unknown>;

The cacheControl lines are the operational heart of the decision. The shell earns immutable because it never contains user- or route-specific content, which is exactly why it scales for free. The SSR response can only be shared-cached when it is anonymous; the moment it embeds a signed-in user’s data it becomes private, no-store, and you pay full render compute on every hit — the cost that makes SSR expensive for heavily personalised routes.

Verification

Measure the trade directly rather than trusting intuition: compare TTFB against the meaningful paint for the same route under each strategy, deep-linking cold so no warm cache hides the difference.

// @playwright/test v1.44
import { test, expect } from "@playwright/test";

test("cached shell wins TTFB but content arrives after hydration", async ({ page }) => {
  await page.goto("/reports", { waitUntil: "commit" });
  const ttfb = await page.evaluate(
    () => performance.getEntriesByType("navigation")[0] as PerformanceNavigationTiming,
  );
  // Shell TTFB should be tiny; assert content is NOT yet in the initial HTML.
  expect(ttfb.responseStart).toBeLessThan(100);
  await expect(page.locator("[data-route='reports'] .content")).toBeVisible(); // appears post-fetch
});

For a config-free check on which strategy a route uses, run curl -s https://your-site/reports | grep -c 'data-route': a match count above zero means the server shipped rendered content (SSR), while an empty skeleton means the content will only appear after the client fetch (cached shell).

Gotchas

  • A cached shell with an immutable header will keep serving a stale build after you deploy unless the shell’s asset URL is content-hashed — otherwise visitors run old code against a new API.
  • SSR responses that embed personalised data must never carry a shared public cache header; one mis-set header can leak one user’s rendered page to the next visitor from the edge cache.
  • Shell caching moves the data round trip to the client, so a slow API turns an instant TTFB into a slow LCP — profile the fetch, not just the first byte, before declaring the shell “faster”.
  • Hydrating a cached shell against server-rendered markup on the same route causes hydration mismatches; pick one strategy per route rather than blending them within a single document.

FAQ

Does a cached app shell rank as well as server-side rendering? Only if crawlers reliably execute its JavaScript and the content arrives inside their budget; SSR ships complete HTML with no execution needed, so for public, content-dense routes that must rank it remains the safer choice.

Why is my TTFB great but my LCP still slow with a cached shell? Because the shell contains no content — the first byte is fast, but the largest paint waits on JavaScript booting and the client-side data fetch, so optimise the API round trip and hydration cost rather than the shell delivery.

Can I cache a server-side-rendered page? Anonymous SSR output can be shared-cached for a short TTL, but as soon as the markup embeds signed-in or personalised data it must be marked private and not stored in a shared cache, which returns you to paying render compute on every request.

When should I prefer full SSR over a cached shell? When a route is publicly discoverable, content-dense, and first-paint critical — SSR reaches meaningful content and LCP sooner and needs no client execution to be indexable, which outweighs its higher per-request cost.

Can I combine shell caching and SSR on one site? Yes, and it is usually the right answer: serve a cached shell for authenticated, interaction-heavy routes where TTFB and repeat-visit speed matter, and server-render the public marketing and content routes that compete in search, deciding per route.

  • SPA vs MPA Tradeoffs — the broader single-page versus multi-page decision that this rendering choice sits inside.
  • App Shell Caching — how to deliver and cache the client-rendered shell so its TTFB advantage is real on repeat visits.
  • When to Choose SPA over MPA for SEO — scoring a route’s crawlability to decide whether it can afford a client-rendered shell at all.