"Where would you store this" and "how would you make it work offline" are design questions whose good answers are full of constraints: quotas, eviction, synchronous versus asynchronous, what SSR can see, and how a service worker update reaches users without breaking the app they have open. The state section covers the application view; this page is the platform.
localStorage, sessionStorage): synchronous, string-only, ~5 MB per origin, blocks the main thread on large reads, fires storage events in other tabs, absent during SSR, readable by any script on the origin. Preferences and small drafts only; never tokens.onupgradeneeded. Use a wrapper (idb for promises, Dexie for a query layer). Right for offline data, large caches and anything above a few hundred kilobytes. Available in workers.caches.open(name), cache.put, cache.match), designed for service workers but usable from pages; the store behind offline shells and runtime caching.navigator.storage.estimate() reports usage and quota, navigator.storage.persist() requests persistent storage (granted heuristically). Safari caps script-writable storage and deletes it after 7 days without interaction for non-installed sites (ITP), which makes "offline forever" a promise you cannot keep on iOS web apps unless installed.register → install (precache the shell; event.waitUntil) → waiting (a new version waits until all tabs using the old one close) → activate (delete old caches; clients.claim()) → fetch events (respond from cache, network, or a strategy). skipWaiting() activates immediately at the cost of mixed versions across open tabs. The browser checks for a byte-different script on navigation (at most every 24 h). Scope is the directory of the script; HTTPS only; no DOM access; can be killed and restarted at any time (no in-memory state).@vite-pwa/nuxt generates the worker, precache manifest and web app manifest, with registerType: 'autoUpdate' or a prompt flow.navigateFallback for SPA routes, and mismatched hashed assets between an old HTML and a new deploy (the same app:chunkError problem Nuxt handles for non-PWA apps).name, icons, display, start_url) plus a service worker makes the app installable; Web Push (PushManager, VAPID keys, push event in the worker) works in all major browsers, on iOS only for installed web apps (16.4+). Background Sync and Periodic Sync are Chromium-only.SharedWorker across tabs; module workers; postMessage with structured clone and transferables; Comlink for RPC ergonomics), BroadcastChannel for cross-tab messages, Web Locks to coordinate tabs (one tab holds the socket), navigator.locks.online event / navigator.onLine is unreliable; probe with a request), idempotency keys so replays are safe, server-side conflict resolution (last-write-wins with versions, or CRDTs), and an explicit UI for pending and conflicted items. Promise offline for a defined subset of features, not the whole product.A service worker with the three strategies most apps need, written without a library so the mechanics are visible:
const VERSION = 'v42' // changed by the build: byte difference triggers the update flow
const SHELL = `shell-${VERSION}`
const RUNTIME = `runtime-${VERSION}`
self.addEventListener('install', (event) => {
event.waitUntil(caches.open(SHELL).then(cache => cache.addAll(['/offline', '/_nuxt/entry.abc123.js', '/_nuxt/entry.abc123.css'])))
})
self.addEventListener('activate', (event) => {
event.waitUntil((async () => {
for (const key of await caches.keys()) if (!key.endsWith(VERSION)) await caches.delete(key) // drop old versions
await self.clients.claim() // control open pages now
})())
})
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url)
if (event.request.method !== 'GET' || url.origin !== location.origin) return
if (url.pathname.startsWith('/_nuxt/')) {
// cache-first: hashed, immutable
event.respondWith(caches.match(event.request).then(hit => hit ?? fetch(event.request).then(res => cacheAndReturn(RUNTIME, event.request, res))))
return
}
if (event.request.mode === 'navigate') {
// network-first with offline fallback for HTML
event.respondWith(fetch(event.request).catch(() => caches.match('/offline') as Promise<Response>))
return
}
if (url.pathname.startsWith('/api/catalog/')) {
// stale-while-revalidate for semi-static data
event.respondWith((async () => {
const cached = await caches.match(event.request)
const network = fetch(event.request).then(res => cacheAndReturn(RUNTIME, event.request, res))
return cached ?? network
})())
}
})
async function cacheAndReturn(name: string, req: Request, res: Response) {
if (res.ok) (await caches.open(name)).put(req, res.clone())
return res
}
The same with @vite-pwa/nuxt, which is what you ship, plus an update prompt:
export default defineNuxtConfig({
modules: ['@vite-pwa/nuxt'],
pwa: {
registerType: 'prompt', // let the user choose when to swap versions
manifest: { name: 'Nuxt Interview Prep', short_name: 'Prep', display: 'standalone', start_url: '/', icons: [{ src: '/icon-512.png', sizes: '512x512', type: 'image/png' }] },
workbox: {
globPatterns: ['**/*.{js,css,html,svg,woff2}'],
navigateFallback: '/offline',
runtimeCaching: [{ urlPattern: /\/api\/catalog\//, handler: 'StaleWhileRevalidate', options: { cacheName: 'catalog', expiration: { maxAgeSeconds: 3600 } } }],
},
},
})
<script setup lang="ts">
const { $pwa } = useNuxtApp()
</script>
<template>
<UAlert v-if="$pwa?.needRefresh" title="A new version is available" :actions="[{ label: 'Reload', onClick: () => $pwa.updateServiceWorker(true) }]" />
</template>
IndexedDB with a sync queue, the core of offline-first:
import { openDB } from 'idb'
export const db = await openDB('prep', 2, {
upgrade(db, oldVersion) {
if (oldVersion < 1) db.createObjectStore('notes', { keyPath: 'id' })
if (oldVersion < 2) db.createObjectStore('outbox', { keyPath: 'id' }).createIndex('byCreated', 'createdAt')
},
})
export async function saveNote(note: Note) {
const tx = db.transaction(['notes', 'outbox'], 'readwrite')
await Promise.all([
tx.objectStore('notes').put(note),
tx.objectStore('outbox').put({ id: crypto.randomUUID(), op: 'upsert', note, createdAt: Date.now() }), // idempotent replay key
tx.done,
])
}
export async function flushOutbox() {
for (const item of await db.getAllFromIndex('outbox', 'byCreated')) {
await $fetch('/api/notes', { method: 'PUT', body: item.note, headers: { 'Idempotency-Key': item.id } })
await db.delete('outbox', item.id)
}
}
Activating a new worker immediately while other tabs still run the old page means those tabs fetch new chunks through a worker whose precache no longer has the old hashed files, and the mismatch surfaces as chunk errors. Either prompt the user to reload all tabs, or let the new worker wait until the old tabs close.
true means "has a network interface", not "can reach the server": captive portals and dead Wi-Fi report online. Treat it as a hint and confirm with a small request; design the sync queue to survive false positives.
Current per-browser quotas and Safari's script-writable storage eviction rule, plus Web Push support on iOS (installed web apps) as of the interview week.
docs ↗skipWaiting() and reproduce the chunk error with two tabs.navigator.storage.estimate() in Safari and Chrome for the same app and compare quotas; read what persist() returns."The browser re-fetches the worker script on navigation, at most once a day, and if a single byte differs it installs the new version in the background: the install event precaches the new shell, then the worker waits until every tab controlled by the old version has closed before activate runs and old caches are deleted. Calling skipWaiting() shortcuts the wait, which is convenient but means tabs still running the old page are served by a worker whose caches belong to the new build, so hashed chunks can go missing. The robust pattern, which @vite-pwa/nuxt implements with registerType: 'prompt', is to detect the waiting worker, show a reload prompt, and only then activate. And HTML is never cached cache-first, or users never see the update at all."
"localStorage for small preferences and drafts, because it is synchronous, string-only, about five megabytes, absent during SSR and readable by any script, so never for tokens. IndexedDB for structured or large data and anything offline: asynchronous, transactional, indexed, with versioned schema migrations and generous quotas, through a wrapper like idb or Dexie. The Cache API for request and response pairs behind a service worker. OPFS for big binary files or a WASM database. Cookies, HttpOnly, for the session. And I remember that all of it is best-effort storage the browser may evict, with Safari deleting script-writable storage after a week of inactivity for non-installed sites, so the server stays the source of truth and the client holds a cache plus a sync queue."
Nothing yet. Notes filed here show up on this page and the section overview.
Fetch, CORS and cookies
What fetch does and does not do, credentials and modes, the same-origin policy and how CORS preflights work, why CORS protects users not servers, cross-origin isolation headers, and every cookie attribute — SameSite, Secure, HttpOnly, prefixes, partitioning — with how Nuxt reads and sets them on both sides.
Web APIs worth knowing by name
The platform APIs a senior frontend engineer should be able to reach for without searching — URL and encoding, Web Crypto, files and blobs, history and the Navigation API, View Transitions, Page Lifecycle and bfcache, the Performance and observer APIs, Speculation Rules, clipboard and share, Web Components and WebAssembly — with the questions each one answers.