Persistence questions test whether you understand the server/client boundary. The URL is visible to both and to the user; cookies are visible to both but small; browser storage is visible only to the client and only after hydration. Choosing wrong produces either hydration mismatches or a server that cannot render what the user expects. This app's own notes and progress features are a worked example of the client-only case.
route.query and route.params, write with router.replace (no history entry) or router.push (history), parse and default with a schema because query values are strings, and keep the URL canonical (drop defaults). VueUse's useRouteQuery/useRouteParams (@vueuse/router) give two-way refs.useCookie works on both sides and serialises JSON; set sameSite, secure, sensible maxAge, and keep them small (4 KB each, sent on every request). Server-set cookies need refreshCookie (or the cookieStore flag) to update the client ref.localStorage / sessionStorage: synchronous, string-only, ~5 MB, blocks the main thread, not available during SSR, readable by any script on the origin (never tokens). Read it after hydration (onMounted, app:suspense:resolve, or VueUse useStorage with initOnMounted: true) so the first client render matches the server's; render a neutral state until then.idb, Dexie). For big caches, offline data and anything above a few hundred kilobytes.version; loading code migrates or discards old versions. Without it, a renamed field breaks every returning user.storage event fires in other tabs on localStorage writes; BroadcastChannel is the explicit alternative. Decide whether tabs share state or last-writer-wins.@vite-pwa/nuxt, Workbox) caches the shell and assets; data lives in IndexedDB with a sync queue; background sync and conflict handling are the hard parts. Promise offline only for a defined subset of features.localStorage is exposed to XSS; session tokens belong in HttpOnly cookies. Personal data in storage needs a clear-on-logout path.Persisted client state that never causes a hydration mismatch (the pattern this app uses):
export function usePersistedState<T extends object>(key: string, init: () => T) {
// Shared per app, serialisable; on the server it holds the default and that is what renders.
const state = useState<T>(`persisted:${key}`, init)
const ready = useState(`persisted-ready:${key}`, () => false)
if (import.meta.client && !ready.value) {
const hydrate = () => {
try {
const raw = localStorage.getItem(`app:v1:${key}`)
if (raw) {
const parsed = JSON.parse(raw) as T & { version?: number }
state.value = parsed.version === 1 ? parsed : init() // migrate or discard old shapes here
}
}
catch { /* corrupt storage: keep defaults */ }
ready.value = true
watch(state, v => localStorage.setItem(`app:v1:${key}`, JSON.stringify(v)), { deep: true })
}
const nuxtApp = useNuxtApp()
// After hydration, so the first client render matches the server HTML.
if (nuxtApp.isHydrating) nuxtApp.hooks.hookOnce('app:suspense:resolve', hydrate)
else hydrate()
}
return { state, ready }
}
A cookie preference the server can honour, and the URL-state composable with canonical query:
export const useDensity = () => useCookie<'compact' | 'comfortable'>('density', {
default: () => 'comfortable',
maxAge: 60 * 60 * 24 * 365,
sameSite: 'lax',
secure: true,
})
import { z } from 'zod'
const schema = z.object({
q: z.string().default(''),
page: z.coerce.number().int().min(1).default(1),
sort: z.enum(['recent', 'popular']).default('recent'),
})
type ListQuery = z.infer<typeof schema>
const defaults = schema.parse({})
export function useListQuery() {
const route = useRoute()
const router = useRouter()
const query = computed<ListQuery>(() => schema.parse(route.query))
function set(patch: Partial<ListQuery>) {
const next = { ...query.value, ...patch }
// Canonical: omit defaults so /list and /list?page=1&sort=recent are the same URL.
const clean = Object.fromEntries(Object.entries(next).filter(([k, v]) => v !== defaults[k as keyof ListQuery]))
router.replace({ query: clean })
}
return { query, set }
}
Cross-tab sync for a draft, and a safe read of a large cache from IndexedDB:
export function useDraft(id: string) {
const draft = ref('')
if (import.meta.client) {
const channel = new BroadcastChannel(`draft:${id}`)
onMounted(() => { draft.value = localStorage.getItem(`draft:${id}`) ?? '' })
watchDebounced(draft, (v) => {
localStorage.setItem(`draft:${id}`, v)
channel.postMessage(v)
}, { debounce: 300 })
channel.onmessage = (e: MessageEvent<string>) => { draft.value = e.data }
onScopeDispose(() => channel.close())
}
return draft
}
const theme = ref(localStorage.getItem('theme')) throws on the server, and guarded with import.meta.client it renders a different first frame than the server HTML: hydration mismatch. Read after hydration, or move the preference to a cookie so the server can render it.
route.query.page is "2", or ["2", "3"] if repeated, or undefined. Parsing with a schema (z.coerce.number()) once in a composable prevents page + 1 === "21" bugs and gives every consumer typed defaults.
usePersistedState, then temporarily read localStorage synchronously in setup and observe the hydration warning; restore the hook-based version.localStorage and in a cookie; reload each and compare the first frame in a throttled network profile.BroadcastChannel and observe the divergence."I first ask whether the server render needs the value. If it does, it goes in a cookie through useCookie, which both sides can read, so there is no flash and no mismatch. If it does not, it goes in browser storage, and I read it only after hydration, on app:suspense:resolve or in onMounted, rendering the default until then so the first client frame equals the server HTML. Every stored shape carries a version so I can migrate it, tabs are synced with BroadcastChannel, large data goes to IndexedDB rather than localStorage, and nothing secret goes into storage at all because any script on the origin can read it."
"Anything the user might want to share, bookmark or navigate back to: filters, sort order, page, the selected tab or item. It is the only state the server, the client and the user all see, so the server can render the right view directly and the back button works. I parse the query with a schema in one composable because values arrive as strings, default the missing ones, and write back with router.replace in canonical form, dropping defaults so equivalent views have one URL."
Nothing yet. Notes filed here show up on this page and the section overview.
Realtime and sync
WebSockets versus Server-Sent Events versus polling, Nitro's WebSocket and event-stream support, reconnection and resumption, keeping clients in sync with optimistic local state, and scaling realtime beyond one server.
JavaScript & the runtime
Why senior frontend interviews still test JavaScript itself — the event loop, closures and this, modules, collections and iterators, memory, dates and numbers, and what ES2024 to ES2026 added — and how the pages in this section fit.