What I Learned from SSR in Vue (and Why JSP Was Already SSR)

Notes from the talk Things I Learned from SSR (「從入門到被開除」 — from onboarding to getting fired, N things I learned about SSR) combined with my own thinking on CSR vs SSR in Vue.

The basics in two paragraphs

CSR (the classic Vue 3 + Vite flow): the server sends an almost-empty HTML plus the JS bundle, and the browser builds the DOM. SSR (typically Nuxt 3): the server runs Vue, generates the full HTML, and the browser shows it instantly; then Vue “adopts” that DOM and attaches the event listeners — hydration.

Hydration is where the SSR golden rule comes from: server and client code must produce the same result. Date.now(), Math.random(), or any access to window are classic sources of hydration mismatch — one of the most frustrating bugs to debug.

Bugs that only exist in SSR

The most valuable part of the talk is the problems that don’t exist in CSR, and they all share the same root cause: the server lives indefinitely; the browser doesn’t.

Memory leaks and request pollution. In the browser, every navigation destroys the state. On the server, the same Node instance serves thousands of requests — a store defined as a global singleton gets shared across every user: state accumulates or, worse, leaks between concurrent requests. The fix is always the factory pattern: a fresh instance per request.

// ❌ global singleton — shared across every request
const store = { user: null }

// ✅ factory — a fresh instance per request
export function createStore() {
  return { user: null }
}

Relative URLs. In the browser, fetch('/api/data') works because window.location exists. In Node there’s no implicit origin — axios.get('/api/posts') throws Invalid URL. Either configure baseURL, or use Nuxt’s $fetch, which resolves it internally in both contexts.

The await waterfall. Two sequential awaits in <script setup> add up their latencies (300ms + 200ms = 500ms); Promise.all overlaps them (300ms). And the useAsyncData + watch combo can trigger a duplicate fetch on mount: Suspense resolves the first one, and the watch — which just mounted — fires the second.

The parallel I care about: JSP was already doing this

Working with Spring Boot + JSP on some projects and Vue on others, this table put things in order for me:

JSP + jQueryNuxt SSR
Who decides the data order?You — imperativeThe framework — declarative
Does it block the render?Never — the HTML is already thereYes, until the async setup resolves
Partial renderFreeRequires nested Suspense

JSP behaves more like Nuxt SSR than like Vue CSR: the HTML arrives already rendered from the server, and JS is a later addition — progressive enhancement, even though nobody called it that in 2005. If you’re coming from JSP + Spring Boot, you already have the SSR intuition; what changes is who orchestrates it (the framework instead of you) and the new cost of hydration.

When to use each one

My operational summary: internal app, dashboard, or admin panel → CSR with Vue 3 + Vite consuming the Spring Boot API. E-commerce, landing page, or public content with critical SEO → SSR with Nuxt. And if you already have JSP + Spring Boot rendering on the server… you’re already in the SSR category; think twice before migrating to pure CSR and losing that.