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.
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
immutableheader 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
publiccache 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.
Related
- 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.