Server rendering produces HTML before the browser executes application code. Hydration then associates that HTML with the client component tree. The initial client output must match the server's structure and data. Browser-only values such as local storage, viewport width, random IDs, and local time zones can break that agreement if read during the first render. There is another boundary on the server: module-level mutable state may be shared across requests in a long-running process. A private case selected by one request must not become the next request's default through a singleton ref. Create application state per request, serialize only the permitted initial snapshot, and apply browser-only preferences after mount.
Vue Server Rendering and Hydration Stability
Working case
A permit app exports a module-level selectedCaseId ref. Reviewer 47 requests the server-rendered page, then reviewer 81 arrives on the same server worker. The second HTML response can inherit 47's selected case before its own data loads. The browser then reads a saved filter from local storage while computing the first view, making its count disagree with the server HTML. The corrected server path creates a fresh app and state container for each request, loads only that reviewer's permitted data, and serializes a versioned snapshot. The first browser render uses that snapshot. After mount, a saved display preference changes the view as a separate, visible update. Authorization remains server-owned.
Implementation boundary
import { ref, onMounted } from "vue";
// Call inside setup in a fresh app instance for each server request.
export function usePermitPageState(initialSnapshot) {
const caseCount = ref(initialSnapshot.caseCount);
const selectedCaseId = ref(initialSnapshot.selectedCaseId);
function applyBrowserPreference() {
try {
const savedValue = window.localStorage.getItem("permit-visible-limit");
if (savedValue === null) return;
const parsedLimit = Number(savedValue);
if (Number.isInteger(parsedLimit) && parsedLimit >= 0) {
caseCount.value = Math.min(initialSnapshot.caseCount, parsedLimit);
}
} catch { /* Keep the server snapshot visible. */ }
}
onMounted(applyBrowserPreference);
return { caseCount, selectedCaseId };
}Construct refs, stores, and request caches inside a factory invoked for each server request. Do not put user-specific mutable state in a process-wide module singleton. Fetch and authorize server data before rendering the route, then serialize the minimum snapshot needed for the first client pass with safe HTML escaping. Give the browser the same version and initial values. Keep render-time code free of window and document access because the server has no browser globals. When local storage or viewport data is necessary, read it after mount and handle access failure. Use stable formatting for dates and IDs on both sides; convert to local time as a later enhancement if the server cannot know the user's zone. Validate HTML structure too: browser parser repairs invalid nesting before hydration sees it. Treat mismatch warnings as defects to investigate, even if the framework can recover by discarding nodes.
Cost and boundaries
Creating state per request prevents cross-user leakage but adds initialization work and requires careful disposal of request-scoped resources. Snapshot serialization increases response bytes and may expose data if the server includes fields not authorized for the viewer. Post-mount personalization adds a second render and can move content, so keep the initial view useful and the transition clear. Rendering nothing on the server avoids some mismatch cases but loses the value of server HTML on slow networks. Measure response size, server render time, mismatch count, hydration time, and visible shifts. A small saved display preference is not permission to send a large private case object to the browser.
Failure trace
Run two simultaneous server requests for different reviewers on the same worker and verify neither response contains the other's selected ID or note. Hydrate in a browser with a different time zone and a saved filter; compare the first browser text with server HTML before the preference applies. Delay JavaScript and confirm that the server output is still useful. Disable local storage access and ensure the page keeps the permitted server snapshot. Insert intentionally invalid nested markup and verify a mismatch check catches browser parser repair. Change the serialized snapshot version and reject incompatible boot data rather than attaching behavior to a different record.
Verification
- Two requests in one server worker cannot share private state.
- Server HTML and first client render use the same authorized snapshot.
- Blocked browser storage does not erase permitted content.
Practice drill
Build a per-request app factory with two authorized permit snapshots: one for reviewer 47 and another for reviewer 81. Render them concurrently, then inspect the resulting HTML and serialized boot data for cross-request state. Hydrate one page with local storage set to show only 29 records, and record the server count, first client count, and post-mount count. Repeat with a different browser time zone and blocked storage. Document which data must match at hydration, which data can arrive later, and which fields must never be serialized for another user.
Decision note
Each server request owns its state; the first client render reuses its permitted snapshot before browser preferences apply.
Common Mistakes
- Exporting user-specific mutable refs from a server module.
- Reading browser-only values during the first render.
- Treating hydration recovery as proof that mismatched markup is harmless.
Related lessons
Vue Reactivity and Component Boundaries; Vue Source State, Computed Views, and Large Snapshots; Vue Watch Cleanup and Stale Request Ownership; Vue Props, Emits, and Keyed Editor Ownership; Server Rendering and Client Data Flow; React Hydration and Stable First Render.
Apply and check
Build Project: Vue permit review queue and review Web Development: Vue reactivity and boundaries quiz.
