Back FoxCode

Frontend internals: 90 concepts

Hydration, fiber, QUIC, CRDTs, compositing, INP - from Vue perspective

·50 min read

Add will-change: transform to every animated element, forget that watch without cleanup creates race conditions, and wonder why their <Suspense> boundaries cause more network waterfalls than they prevent. ( ͡° ͜ʖ ͡°)

90 concepts, grouped by theme, each explained, from Vue perspective throughout.

Rendering & hydration

Hydration

Hydration is the process of attaching JavaScript event handlers and reactivity to HTML that was already rendered on the server. The server sends a fully-formed HTML string; the browser displays it immediately. Then the JavaScript bundle loads, runs, walks the existing DOM (instead of creating new elements), and "wires up" the Vue component tree to that DOM - attaching event listeners, watchers, and refs.

The browser parses and executes the full bundle, reconstructs the virtual DOM, compares it to the real DOM (they must match exactly or you get a hydration mismatch warning), and registers all event handlers. On a slow device this can take 3-10 seconds during which the page looks interactive but isn't. That gap is what FID and INP measure.

In Nuxt, hydration starts when nuxtApp.vueApp.mount() is called on the client.

Partial hydration

Instead of hydrating the entire page, partial hydration hydrates only the components that actually need client-side interactivity. A static pricing table doesn't need JavaScript. A carousel does. Partial hydration lets you ship the carousel's bundle while leaving the pricing table as inert HTML.

The catch: components opted out of hydration must produce identical output on server and client, and the framework still needs discipline about what touches server-only state. In Nuxt, <ClientOnly> and lazy-loaded components approximate this - but it's opt-in, not the default.

Islands architecture

Islands architecture (the term comes from Jason Miller's 2019 post) takes partial hydration to its logical conclusion. The page is mostly static HTML ("the ocean") with isolated interactive components ("islands"). Each island hydrates independently with its own bundle. No shared framework runtime between islands.

Astro popularized this for content sites - the entire page is zero-JS by default, you opt in to interactivity per component. Nuxt flips this around. Instead of static page + dynamic islands, Nuxt gives you a full Vue SPA with SSR where you can embed static islands inside a dynamic app. Name a component .server.vue (or place it in components/islands/) and Nuxt renders it exclusively on the server via <NuxtIsland>, ships only the HTML, and never hydrates it. The component and all its dependencies - heavy markdown parsers, syntax highlighters, whatever - are completely excluded from the client bundle. Zero JS for that component.

With selectiveClient enabled, you can mark specific children inside a server component with the nuxt-client attribute - so a mostly-static island can still have one interactive counter or form inside it. You can even render islands from a remote server with the source prop, which is useful for micro-frontend patterns.

The tradeoff is real though: Astro's default is zero JS everywhere, Nuxt's default is full hydration everywhere. You're opting out of JS in Nuxt, not opting in. And the feature still lives behind experimental.componentIslands in nuxt.config - it's been increasingly stable since Nuxt 3.8 but hasn't graduated to stable as of Nuxt 4. The server components roadmap tracks remaining work.

Streaming SSR

Standard SSR blocks the response until the entire HTML is ready. Streaming SSR uses HTTP chunked transfer encoding to send HTML to the browser before all async data resolves. The browser starts parsing and rendering the <head> and above-the-fold content while the server is still fetching data for the rest of the page.

The practical gain: LCP for above-the-fold content improves even when some below-fold data is slow.

The catch: streaming makes error handling harder because you've already started writing the response when a downstream error occurs - you can't retroactively change the HTTP status code.

Nuxt 3/4 supports streaming via Nitro's H3 layer and Vue's renderToWebStream.

Concurrent rendering

Concurrent rendering means the renderer can interrupt, pause, and resume rendering work. In a traditional synchronous renderer, a large component tree blocks the main thread until rendering is complete. Concurrent rendering breaks work into units that can be interrupted if something higher priority arrives - a user interaction, for instance.

Vue 3's scheduler yields to the browser between component updates via microtask scheduling. It doesn't expose a formal "concurrent mode" API, but the underlying principle is the same: rendering work shouldn't monopolize the main thread.

Time slicing

Time slicing is the mechanism behind concurrent rendering. Rendering is broken into slices timed to yield before each 16ms frame deadline. If a rendering task takes 50ms synchronously, it blocks three frames - noticeable jank. With time slicing, the renderer yields after each slice, the browser paints a frame, then rendering resumes.

In practice, Vue's scheduler handles this via nextTick batching - all component updates within a single tick are processed together, then control returns to the browser. For custom heavy work in your own code, scheduler.yield() is the native API to achieve the same effect.

Selective hydration

A refinement of partial hydration where the framework prioritizes which parts of the page to hydrate based on user interaction. If a user clicks on a part of the page that hasn't hydrated yet, the framework immediately prioritizes hydrating that component over the normal top-down order.

The result: the thing the user is actually trying to interact with becomes interactive faster, even if overall hydration isn't complete. Nuxt doesn't implement this automatically yet - it's more of a target architecture than a built-in behavior.

Server components

Server components run exclusively on the server and are never hydrated. They can access databases directly, read from the filesystem, use server-only secrets - and their code is never sent to the browser. They output serialized component descriptions that the client renderer uses to display HTML.

The key distinction from regular SSR: a server component has zero client-side JavaScript footprint. A server-rendered component (traditional SSR) still ships all its JavaScript for hydration.

In Nuxt 4, server components are available via the .server.vue suffix. State management across server/client boundaries is complex - client-side interactions must stay in client components.

Edge rendering

Edge rendering moves SSR from a centralized origin server to edge locations close to the user (Cloudflare Workers, Vercel Edge Runtime). The goal: reduce latency from "user requests page" to "server starts sending HTML" from 100-300ms to under 30ms.

The constraint: edge runtimes are V8 isolates, not Node.js. No fs, limited crypto, no native addons. Nuxt's Nitro handles this via universal adapters, but if your SSR code calls require('better-sqlite3') you're not running at the edge.

Suspense boundaries

A Suspense boundary wraps async components and shows a fallback while children load. In Vue 3, <Suspense> resolves all async setup() functions in its subtree before rendering children - no special "throw a promise" magic needed.

In streaming SSR, each Suspense boundary is a stream boundary - the server streams the outer shell, shows the fallback, then streams the resolved content when the async data is ready. Multiple <Suspense> on a page mean multiple independent streaming segments.

Gotcha with nesting: an inner <Suspense> boundary won't surface its fallback if an outer one hasn't resolved yet. The outer boundary takes priority and shows its own fallback.

Framework internals

Reconciliation algorithm

Reconciliation is how a virtual DOM framework decides which real DOM operations to perform when state changes. Instead of re-rendering the entire DOM, the framework compares the previous virtual DOM tree to the new one and generates a minimal list of mutations.

The baseline complexity of tree diffing is O(n³). Vue (and other virtual DOM frameworks) reduce this to O(n) with two heuristics: (1) elements of different types produce different trees - don't diff them, just replace; (2) elements with stable key props can be tracked across list positions.

This is why key in v-for isn't optional - it's the signal that enables O(n) reconciliation for list updates.

Fiber architecture

Fiber is the name of React's internal rendering architecture - worth understanding even from a Vue perspective because the problem it solves is universal. It replaced recursive call-stack rendering with an explicit linked list of work units (fibers), one per element. Because work is an explicit data structure rather than a call stack, the renderer can pause between units, save its position, and resume later. A call stack can't be interrupted.

Vue 3 solves the same problem differently. Instead of per-element fibers, Vue schedules re-renders at the component boundary - each component is an independent update unit. When state changes, Vue queues that component's re-render via nextTick, then flushes the queue in one batch and yields to the browser. The VNode diffing within each component is still synchronous, but components themselves are the granularity of scheduling.

Virtual DOM diffing complexity

Even with O(n) heuristics, diffing has practical costs. A component tree with 500 nodes means 500 fiber/vnode comparisons on every render that reaches it. The "virtual DOM is fast" claim needs qualification: it's fast compared to naively re-rendering the real DOM every time, not compared to surgical reactive updates - which is what Svelte and Vue's fine-grained reactivity do.

For very large lists, windowing (rendering only visible items via vue-virtual-scroller) beats any diffing optimization. For deep trees, v-memo prunes the diff tree before it runs - if the memoized dependencies haven't changed, Vue skips the entire subtree.

Structural sharing

Structural sharing is how immutable data structures avoid copying entire objects on update. When you update a nested field, you create new object references along the path from root to the changed node, while sharing unchanged branches with the original.

const original = { a: { x: 1 }, b: { y: 2 } }

// Update a.x to 99:
const updated = {
  ...original,               // shares reference to original.b
  a: { ...original.a, x: 99 }
}

// original.b === updated.b  -> true (shared, not copied)

Immer uses structural sharing internally when you write mutating-style code inside produce(). This matters because referential equality checks (===) are how frameworks skip unnecessary re-renders - shared branches pass equality checks automatically.

JavaScript patterns & gotchas

Immutable data patterns

Immutable data means values that never change after creation. When you need a "modified" version, you create a new object. The consequence in Vue: returning the same object reference from a computed means "nothing changed, skip re-render." Mutating the same object in place means "nothing changed" to a referential equality check - a silent bug.

Pinia encourages mutation-style via $patch but tracks changes internally via Vue's reactivity proxy, not reference equality. This is the main difference between Pinia and pure immutable stores - you don't need to think about structural sharing, Vue's proxy handles change detection for you.

Referential equality

In JavaScript, === on objects and arrays checks identity - whether both sides point to the same object in memory, not whether they have the same content. { a: 1 } === { a: 1 } is false.

This bites Vue's computed and watch constantly. In computed(() => transform({ id })) - the { id } literal creates a new object every evaluation, but since id is a primitive, Vue correctly tracks id as the reactive dependency. The problem appears when you pass object results as watch sources or compare them manually:

// This watch fires on every render - new object reference every time
watch(() => ({ id: user.value.id }), handler)

// This doesn't - primitive reference is stable
watch(() => user.value.id, handler)

Fix: stabilize references. Derive primitives first, use them as watch sources.

Memoization pitfalls

Memoization caches a function's result based on its arguments, returning cached results on subsequent calls with identical arguments. The gotchas:

  1. Argument identity: memoization uses reference equality for objects. Two separate {a:1} objects are different cache keys - the cache is never hit.
  2. Cache size: a basic memoize utility without an LRU grows unbounded with diverse inputs.
  3. Over-memoization: computed() has overhead (proxy tracking, dirty checking). Wrapping every trivial value in computed is net negative - use it for genuinely expensive derivations.
  4. Stale reads from non-reactive sources: a computed that reads from a plain variable (not a ref or reactive store) silently doesn't invalidate when that variable changes.

Vue's computed() auto-tracks reactive dependencies via the proxy - no dependency array to declare. But a computed that calls Date.now(), reads localStorage, or uses a non-ref module-level variable is not tracking a reactive dependency. It will cache indefinitely and never re-run.

Stale closure problem

A stale closure captures a variable's value at closure creation time. If that variable changes later, the closure still sees the old value.

In Vue, reactivity via .value makes this less common than in other frameworks - you're dereferencing the ref at call time, not capturing a snapshot. But it still bites in two situations:

// 1. Closure over a plain variable inside a composable
export function useTimer() {
  let count = 0  // plain variable, not reactive

  const start = () => {
    setInterval(() => {
      // count incremented but nobody is watching it
      count++
      console.log(count)  // works locally
    }, 1000)
  }

  // components reading count get the initial value - stale closure
  return { count, start }
}

// Fix: use ref
export function useTimer() {
  const count = ref(0)
  const start = () => setInterval(() => count.value++, 1000)
  return { count, start }
}
// 2. Async callbacks that capture reactive state at call time
const user = ref({ name: 'Alice' })

setTimeout(() => {
  // This reads user.value at execution time - fine for refs
  console.log(user.value.name)

  // This captures the primitive at setTimeout call time - stale
  const name = user.value.name  // captured now
  setTimeout(() => console.log(name), 5000)  // may be wrong in 5s
}, 1000)

The rule: if you need reactive values inside async callbacks or timers, read .value as late as possible - at execution time, not at setup time.

Browser & event loop

Event loop

JavaScript is single-threaded. The event loop decides what to execute next. It has two queues: the macrotask queue (task queue) and the microtask queue.

Each event loop iteration: run the current macrotask to completion, then drain the entire microtask queue (including any queued by other microtasks), then render if needed, then pick the next macrotask.

Macrotasks: setTimeout, setInterval, I/O callbacks, UI events, MessageChannel. Microtasks: Promise.then/catch/finally, queueMicrotask, MutationObserver callbacks.

setTimeout(() => console.log('macro'), 0)
Promise.resolve().then(() => console.log('micro'))
console.log('sync')
// Output: sync, micro, macro

The "0ms setTimeout" means "queue a macrotask after current code and all pending microtasks finish" - not "run immediately."

This is why setTimeout(fn, 0) became a classic trick for "run this after the current work finishes" - deferring DOM reads after Vue's reactive updates flush, breaking up long computations to avoid freezing the UI, or ensuring a newly inserted element is in the DOM before querying it. It works, but it's a blunt instrument. The callback lands in the macrotask queue, which means the browser might paint a frame, run other timers, or process user input before your code runs. The delay isn't zero - it's "whenever the event loop gets around to it" which on a busy page can be 4-15ms (browsers clamp nested setTimeout to a minimum of 4ms anyway).

Better alternatives exist depending on what you're actually trying to do. queueMicrotask(fn) runs after the current task but before the next render - tighter timing, no frame gap. Vue's nextTick() is essentially this - it queues your callback as a microtask that runs after Vue's DOM updates flush. requestAnimationFrame(fn) runs right before the next paint - ideal for visual work. And scheduler.yield() (newer API) explicitly yields to the browser for input processing and then resumes - the most intentional way to say "let the user interact, then continue."

setTimeout(fn, 0) still has its place when you genuinely want macrotask semantics - for instance, breaking an infinite microtask loop or ensuring other queued events process first. But reaching for it by default is a code smell that usually means you're fighting the framework's timing instead of working with it.

Task starvation

Because the microtask queue must be fully drained before the event loop can proceed to rendering or the next macrotask, an infinitely-growing microtask queue starves the browser.

// This freezes the tab
function starve() {
  Promise.resolve().then(starve)
}
starve()

Real-world: recursive promise chains without yield points, or RxJS observables that emit synchronously on subscribe. The fix is yielding to the macrotask queue periodically via setTimeout(fn, 0) or the newer scheduler.yield().

Layout thrashing

The browser maintains separate phases: JavaScript runs, then style recalculation, then layout, then paint. Reading layout properties like offsetWidth, scrollTop, getBoundingClientRect after a DOM mutation forces the browser to flush its pending layout queue synchronously - this is a forced reflow.

Layout thrashing is reading and writing layout properties in an interleaved loop:

// Thrashing: forces a full reflow on every iteration
elements.forEach(el => {
  const width = el.offsetWidth          // forces layout flush
  el.style.width = width + 10 + 'px'  // invalidates layout
})

// Fixed: batch reads, then batch writes
const widths = elements.map(el => el.offsetWidth)
elements.forEach((el, i) => el.style.width = widths[i] + 10 + 'px')

Virtual DOM frameworks mostly avoid this by batching all DOM mutations together - a genuine advantage of framework-managed rendering over imperative code.

Critical rendering path

The critical rendering path is the sequence the browser must complete before displaying anything: fetch HTML, parse HTML to DOM, fetch and parse CSS to CSSOM, combine into render tree, calculate layout, paint. Any resource that must be fetched and processed before this completes delays first paint.

Inlining critical CSS (styles needed for above-the-fold content) eliminates at least one round-trip from the critical path. Nuxt and Vite do this automatically with CSS code splitting.

Render blocking resources

Any <link rel="stylesheet"> in <head> blocks rendering until the browser downloads and parses the CSS. <script> tags without async or defer block HTML parsing entirely (the script might call document.write()).

<!-- Render blocking: -->
<link rel="stylesheet" href="modal-only-styles.css">
<script src="analytics.js"></script>

<!-- Non-blocking: -->
<link rel="stylesheet" href="modal-only-styles.css" media="print" onload="this.media='all'">
<script src="analytics.js" defer></script>

The media="print" trick: the browser downloads the stylesheet but doesn't block rendering for it. The onload handler switches it to all-media once loaded. This is how performance-focused sites load non-critical CSS without flash.

Compositing & painting

Browser compositing layers

The browser doesn't paint the page as one flat image. It splits it into layers - independent surfaces composited (combined) by the GPU. Layers can be moved, scaled, or faded without a repaint - the GPU handles it.

Layer promotion happens automatically for: CSS transform/opacity animations, position: fixed elements, will-change, <video>, <canvas>, and iframes. Inspect them in Chrome DevTools > Layers panel.

Each layer consumes GPU memory. Promoting thousands of elements to "optimize" them crashes mobile devices.

Paint vs composite vs layout

Three distinct operations, in order of cost:

  • Layout (reflow): calculate size and position of every element. Triggered by changes to geometry (width, height, margin, padding, font size, adding/removing nodes). Most expensive.
  • Paint: fill in pixels for each layer - text, colors, shadows, borders. Triggered by visual changes that don't affect geometry (color, background). Moderate cost.
  • Composite: assemble layers and display. Triggered by GPU-accelerated properties (transform, opacity). Cheapest by far.

For smooth 60fps animation: animate only transform and opacity. Both skip layout and paint entirely.

Animating left/top/width/height triggers layout every frame - guaranteed jank.

GPU acceleration in CSS

Placing a property on the compositor thread requires the browser to promote the element to its own compositing layer. transform: translateZ(0) and will-change: transform force this.

.animated-element {
  will-change: transform;  /* promotes to GPU layer before animation starts */
}

will-change should be used sparingly and only on elements that will actually animate soon. It's a promise to the browser that benefits from layer promotion. Overusing it means GPU memory spent on layers that never move.

CSS containment

CSS containment (contain property) tells the browser that a subtree is independent from the rest of the page for layout, style, and paint calculations. If an element has contain: layout, changes inside it can't affect anything outside - so the browser skips checking the entire document during recalculation.

.card {
  contain: content; /* layout + style + paint */
}

content-visibility: auto (built on containment) is arguably the highest-leverage single-line optimization for content-heavy pages. It skips rendering off-screen elements entirely and renders them only when they enter the viewport. The browser still reserves their layout space - scroll height stays correct.

Subpixel rendering

On a 2x DPR (device pixel ratio) display, 1 CSS pixel = 2x2 physical pixels. When an element is positioned at a non-integer CSS pixel value (left: 33.5px), the browser must anti-alias across physical pixel boundaries.

getBoundingClientRect() returns floating-point values. When using these values to position an overlay or tooltip, Math.round() the values first - otherwise you'll see blurry borders on non-retina displays.

Observer APIs

IntersectionObserver internals

IntersectionObserver fires callbacks when an element enters or exits the viewport (or a scroll container). The key implementation detail: it doesn't run on every scroll event. It runs at the same time as requestAnimationFrame - once per frame, after layout but before paint. Far cheaper than scroll event listeners with manual position checks.

Gotcha: IO callbacks are asynchronous to the render cycle. If your callback modifies layout (adds elements, changes heights), you can cause jank even though the callback itself is "cheap."

ResizeObserver loop limits

ResizeObserver calls a callback when an element's size changes. The loop limit error - ResizeObserver loop limit exceeded - happens when a ResizeObserver callback causes the observed element to resize, triggering another callback, infinitely. The browser detects and suppresses this.

This usually means your callback modifies layout in a way that affects the observed element. Fix: use requestAnimationFrame to defer layout-changing work out of the callback, or debounce it.

MutationObserver cost

MutationObserver fires when the DOM changes - insertions, deletions, attribute changes, text content changes. Internally it's a microtask queue - mutations are batched and the callback fires as a microtask after the current task completes.

The cost is proportional to what you observe. Watching a root node with subtree: true and childList: true means every DOM insertion anywhere in the document is tracked. In a component framework that creates hundreds of nodes on mount, this adds up. If you see large groups of microtask entries in DevTools traces, check your MutationObserver usage - analytics scripts are common culprits.

Build & bundling

Tree shaking internals

Tree shaking is dead code elimination based on static analysis of ES module imports. Bundlers (Rollup, Vite, Webpack 5) parse the import graph, identify which exports are actually used, and exclude unused code from the bundle.

It only works with ES modules, not CommonJS, because CommonJS imports are evaluated at runtime and can't be statically analyzed. This is why migrating packages to ESM matters.

The gotcha: side effects. A module that modifies globalThis, registers something, or patches prototypes on import can't be safely removed even if nothing is imported from it. The "sideEffects": false field in package.json is an explicit declaration that all modules in the package are side-effect-free.

{
  "sideEffects": ["*.css", "!src/polyfills.js"]
}

Code splitting strategies

Code splitting divides the bundle into chunks loaded on demand. Three main strategies:

  1. Route-based: one chunk per page/route. The Nuxt default - each page component is automatically split.
  2. Component-based: heavy components (rich text editor, chart library) loaded only when needed.
  3. Vendor splitting: separate chunk for node_modules that changes less often than app code - better long-term caching.

The tradeoff: more chunks means more HTTP requests (mitigated by HTTP/2 multiplexing) and more waterfall risk (chunk A loads, discovers it needs chunk B, requests chunk B).

Dynamic import chunking

import() (dynamic import) tells the bundler to create a split point. Vite and Nuxt handle route-level splits automatically, but manual dynamic imports control the chunk graph.

// Loaded eagerly (in main bundle)
import { HeavyEditor } from './HeavyEditor'

// Loaded on demand (separate chunk, hashed filename)
const HeavyEditor = () => import('./HeavyEditor')

Chunk naming matters for caching. Vite generates content-hash filenames (HeavyEditor.Ba3f9c2.js). On rebuild, if only HeavyEditor changed, only that chunk's hash changes - users re-download only that file. This is why separating stable third-party code into a vendor chunk is a genuine caching win.

Module federation

Module federation (Webpack 5+, also a Vite plugin) lets separately-deployed applications share JavaScript modules at runtime - without bundling them together at build time. Application A exposes a component; Application B loads and renders it without importing it during A's build.

The use case is micro-frontends at scale: multiple independent deployment units sharing UI components or a design system. The complexity is real: shared dependencies (Vue, Nuxt modules) must be version-compatible across remotes, TypeScript types for remote modules are awkward, and network failures need graceful fallbacks.

For most teams this is overkill. The genuine use case is large organizations with multiple independent teams that need to deploy independently while sharing UI.

Web Components & platform APIs

Shadow DOM

Shadow DOM is browser-native encapsulation. A shadow root attached to an element creates a scoped DOM tree: CSS inside can't leak out, CSS outside can't leak in (except inherited properties and CSS custom properties), and document.querySelector can't reach inside.

const host = document.querySelector('#card')
const shadow = host.attachShadow({ mode: 'open' })
shadow.innerHTML = `
  <style>p { color: red }</style>
  <p>This red doesn't affect the main document</p>
`

mode: 'open' means JavaScript can access the shadow root via element.shadowRoot. mode: 'closed' returns null. Browsers use closed shadow roots for their own UI elements - the native <input type="date"> datepicker, <video> controls.

Custom Elements lifecycle

Custom Elements are user-defined HTML elements with lifecycle callbacks:

CallbackWhen it fires
constructor()Element created in memory
connectedCallback()Element attached to the document
disconnectedCallback()Element removed from the document
attributeChangedCallback(name, old, new)Observed attribute changed
adoptedCallback()Element moved to a new document

Critical gotcha: attributeChangedCallback only fires for attributes listed in static observedAttributes. Forget to declare one and mutations to it are silently ignored.

connectedCallback can fire before children are parsed. If your element needs to read its child nodes in connectedCallback, use setTimeout(fn, 0) or a MutationObserver - the browser creates elements as it encounters opening tags during HTML parsing.

Web Components interoperability

Using Web Components inside Vue works well for most cases. The friction points: custom elements dispatch CustomEvent (not Vue component events), attribute vs property setting (Vue sets properties by default; HTML attributes are strings), and SSR (custom elements can't run server-side without a DOM polyfill).

Vue 3 has native Web Components support via defineCustomElement, which compiles a Vue SFC into a custom element that works anywhere. For Nuxt's Islands pattern or design system components that need to work outside Vue, this is a legitimate approach. For a purely internal Vue codebase, there's no real benefit over standard Vue components.

Threading & workers

Web Workers vs Service Workers

Web WorkerService Worker
Runs inBackground threadBackground thread + survives page close
DOM accessNoNo
Network interceptionNoYes
ScopePer-pagePer-origin
Use caseCPU-heavy computationOffline caching, background sync, push notifications

A Web Worker is a background thread for your page. You create it, communicate with postMessage, and it's gone when the page closes. CPU-heavy tasks (image processing, cryptography, parsing large datasets) belong here.

A Service Worker is a persistent proxy between your page and the network. It intercepts all fetch requests from your origin, can return cached responses, and survives between page visits. It's the foundation of PWA offline capability.

SharedArrayBuffer

SharedArrayBuffer lets multiple threads (main thread + workers) read and write the same block of memory simultaneously. Without it, every postMessage serializes and copies data. With SharedArrayBuffer you have true shared memory - including all the race condition complexity that comes with it.

Due to Spectre exploitation risks, SharedArrayBuffer requires cross-origin isolation: Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp headers. This breaks third-party iframes and CDN-hosted assets unless they opt in explicitly.

Use Atomics for synchronization when multiple threads access the same SharedArrayBuffer.

Transferable objects

postMessage with large data (a 100MB ArrayBuffer) copies the data - O(n) time and memory. Transferable objects transfer ownership instead of copying: the sender's reference becomes unusable, and the transfer is O(1).

const buffer = new ArrayBuffer(100_000_000)
worker.postMessage({ data: buffer }, [buffer])
// buffer is now detached in the main thread - it was transferred

Transferable types: ArrayBuffer, MessagePort, ImageBitmap, OffscreenCanvas, ReadableStream, WritableStream, TransformStream.

OffscreenCanvas

A Canvas not attached to the document. You can transfer it to a Web Worker and run all rendering there - completely off the main thread. This is the right approach for heavy canvas animations that would otherwise cause jank by occupying the main thread.

const canvas = document.querySelector('canvas')
const offscreen = canvas.transferControlToOffscreen()
worker.postMessage({ canvas: offscreen }, [offscreen])
// All canvas operations now happen in the worker

Three.js supports OffscreenCanvas. Pixi.js has experimental support. For production use, test carefully - support is solid on desktop, more variable on mobile.

WebAssembly integration

WebAssembly (WASM) is a binary instruction format that runs in browsers at near-native speed. Languages compiled to WASM (C, C++, Rust, Go) can run at speeds 10-100x faster than equivalent JavaScript for CPU-bound tasks. WASM doesn't access the DOM - communication happens via shared memory or postMessage.

const wasm = await WebAssembly.instantiateStreaming(fetch('/module.wasm'), imports)
const result = wasm.instance.exports.process(inputPtr, inputLength)

In Nuxt with Vite, WASM files can be imported directly. Real use cases: image/video processing, audio DSP, cryptography, SQLite in the browser (sql.js), PDF rendering. WASM it's for specific CPU-bound tasks where JS's JIT isn't fast enough.

Storage & offline

IndexedDB

IndexedDB is a transactional key-value store with structured data (not just strings like localStorage). It's asynchronous, supports gigabytes of data, and has complex querying via indexes. The raw API is notoriously verbose. Dexie.js wraps it in a usable Promise-based API:

import Dexie from 'dexie'
const db = new Dexie('AppDB')
db.version(1).stores({ posts: '++id, slug, publishedAt' })
const recent = await db.posts.where('publishedAt').above(lastWeek).toArray()
IDB operations in a Service Worker share the same database as the page, but simultaneous version upgrades from both contexts cause blocking. Test your upgrade logic carefully.

Service Worker lifecycle traps

A Service Worker transitions: installing -> installed -> activating -> activated. The traps:

  1. Stuck in installing: if the SW file fails to download or throws during install, it gets stuck. The old SW keeps serving.
  2. Waiting state: a new SW installs but waits for all existing pages to close before activating. This is correct - two SW versions running simultaneously would conflict. self.skipWaiting() in install forces immediate activation (can break things if old and new SW expect different cache shapes).
  3. Outdated clients: an activated SW may serve cached JS that loads the old SW version. clients.claim() in activate takes control of all open clients immediately.

Workbox handles most of this correctly by default.

Cache invalidation strategies

Five strategies, all implemented by Workbox:

StrategyLogicBest for
Cache firstCache hit? Return it. Miss? Fetch & cache.Static assets with hashed filenames
Network firstTry network. Fail? Return cache.API data that must be fresh
Stale-while-revalidateReturn cache immediately, refresh in backgroundSemi-static content, UI shell
Cache onlyNever network.Pre-cached offline app shell
Network onlyNever cache.Non-idempotent requests

The right strategy is per-resource-type, not per-site. A genuinely useful offline experience maps each resource type to the appropriate strategy.

Stale-while-revalidate

Stale-while-revalidate returns a cached response immediately, then fires a background request to refresh it. The user sees content instantly; the next load (or navigation if the background request completes in time) shows fresh data.

Cache-Control: max-age=3600, stale-while-revalidate=86400

This means: treat as fresh for 1 hour, serve stale for up to 24 hours while refreshing in background. Browsers and CDNs handle this automatically when the header is present.

The SWR hook library borrows its name from this pattern. useSWR returns cached data immediately and fetches fresh data in the background.

Networking & resources

ETag vs Cache-Control

Cache-Control controls whether the browser sends a request at all. ETag controls what happens when it does.

Cache-Control: max-age=31536000, immutable means "don't even check for a year." Cache-Control: no-cache means "always check." When the browser does check, it sends If-None-Match: [etag-value] - if it matches the server's current ETag, the response is 304 Not Modified with no body.

For static assets with content-hash filenames (which Nuxt/Vite generates: main.Ba3f9c2.js), use Cache-Control: public, max-age=31536000, immutable. No ETag needed - the filename encodes freshness. For HTML, use Cache-Control: no-cache + ETag for conditional GETs.

HTTP/3 and QUIC

HTTP/3 replaces TCP with QUIC (UDP-based) as the transport layer.

  • No head-of-line blocking at transport level: HTTP/2 multiplexes streams over a single TCP connection. One lost TCP packet stalls all HTTP/2 streams. QUIC has independent streams - a lost packet blocks only that specific stream.
  • Faster connection setup: QUIC + TLS 1.3 takes 1 RTT (vs 3+ for TCP+TLS). Repeated connections from known clients take 0 RTT.
  • Connection migration: QUIC uses a connection ID, not the IP:port tuple. A mobile user switching WiFi to cellular keeps their QUIC connection alive.

HTTP/3 is supported in all modern browsers. Cloudflare, Fastly, and most CDNs support it. Nuxt app behind Cloudflare is probably already serving HTTP/3.

Priority hints

The fetchpriority attribute tells the browser which resources are most important so it can schedule fetches accordingly.

<!-- Hero image is the LCP element - needs high priority -->
<img src="/hero.jpg" fetchpriority="high">

<!-- Below-fold images don't need to compete with LCP -->
<img src="/card.jpg" fetchpriority="low" loading="lazy">

Also works with <link rel="preload"> and fetch():

fetch('/api/critical-data', { priority: 'high' })

Without explicit hints, the browser guesses based on resource type and position in HTML. An LCP image discovered late (via CSS background, injected by JavaScript) gets a low priority guess. fetchpriority="high" overrides that.

Preload vs Prefetch vs Preconnect

  • preload - fetch this resource immediately, before the parser finds it. Use for critical current-page resources: LCP image, key font, critical script. Wrong use: preloading resources the page doesn't actually need (DevTools warns).
  • prefetch - fetch in idle time, for likely future navigation. Lower priority than current-page resources. Nuxt's router automatically prefetches linked pages on hover.
  • preconnect - establish the TCP+TLS connection to a domain early. No resource fetched, just the connection warmed up. Use for third-party origins you know you'll need. Limit to 3-4 - each connection has memory cost.

CORS preflight

CORS restricts cross-origin HTTP requests. A "preflight" is an OPTIONS request the browser automatically sends before certain cross-origin requests to ask "are you willing to handle this?"

Preflight triggers for: non-simple methods (PUT, DELETE, PATCH), custom headers (Authorization, Content-Type: application/json).

OPTIONS /api/data HTTP/1.1
Origin: https://app.example.com
Access-Control-Request-Method: POST

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: POST
Access-Control-Max-Age: 86400

Access-Control-Max-Age is often forgotten. Without it, the browser sends a preflight for every non-simple request. On an API that receives 10 requests per session, that's 10 extra OPTIONS round-trips.

Speculative prerendering

Speculation rules (newer browser API) allow pages to be fully prerendered - including JavaScript execution - in a hidden background tab.

<script type="speculationrules">
{
  "prerender": [{
    "where": { "href_matches": "/product/*" },
    "eagerness": "moderate"
  }]
}
</script>

When the user navigates to /product/123, the page is already rendered and ready to display instantly. This is a step beyond prefetch - prerendering runs the full page lifecycle.

Chrome 108+ supports it.

Prerendering fires analytics beacons and initializes third-party scripts for pages the user may never visit.

Security

SameSite controls when the browser sends cookies with cross-site requests:

ValueCookie sent whenNotes
StrictOnly same-site requestsBest CSRF protection, breaks OAuth flows
Lax (default)Same-site + top-level GET navigationsGood balance; Chrome default since 2020
NoneAll cross-site requestsRequires Secure flag; needed for embedded iframes, payment widgets

Since Chrome 80 (2020), cookies without a SameSite attribute default to Lax. This broke a lot of third-party integrations that relied on cookies being sent with cross-site requests.

CSRF vs XSS mitigation

CSRF (Cross-Site Request Forgery): an attacker tricks a user's browser into making an authenticated request to a site they're logged into. Mitigations: SameSite=Lax/Strict cookies (modern), CSRF tokens in forms (traditional), checking the Origin header.

XSS (Cross-Site Scripting): an attacker injects JavaScript into a page that runs in a victim's browser, stealing session cookies or performing actions as the user. Mitigations: Content Security Policy, sanitizing user input before rendering, using textContent not innerHTML for untrusted data, HttpOnly cookies (unreadable by JS even if XSS succeeds).

XSS is generally worse than CSRF: successful XSS means arbitrary JavaScript execution, which defeats CSRF tokens and SameSite cookies entirely.

Content Security Policy (CSP)

CSP is a response header that tells the browser which sources are trusted for scripts, styles, images, fonts, and more.

Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'nonce-abc123';
  style-src 'self' 'unsafe-inline';
  img-src 'self' data: https://images.cdn.com;

Nonce-based CSP: the server generates a random nonce per request, includes it in the header, and adds nonce="abc123" to each legitimate inline script. The browser executes only scripts with a matching nonce - injected scripts can't know the nonce.

Nuxt 4 has built-in CSP support in Nitro. Start with Content-Security-Policy-Report-Only in report mode before enforcing - production CSP needs careful iteration.

Trusted Types

Trusted Types prevents DOM XSS by requiring typed objects (not raw strings) to be passed to dangerous sinks like innerHTML, eval, and document.write. You define a policy that sanitizes strings into Trusted HTML; the browser enforces only Trusted HTML can be assigned to innerHTML.

const policy = trustedTypes.createPolicy('default', {
  createHTML: str => DOMPurify.sanitize(str)
})

element.innerHTML = policy.createHTML(userContent)  // OK
element.innerHTML = userContent                      // TypeError

Vue's v-html uses innerHTML directly and bypasses Trusted Types. If you need v-html with any user-controlled content, you need explicit sanitization (DOMPurify) before passing to v-html, or a custom Trusted Types policy.

In most cases, the right answer is to not use v-html with user content at all.

DOM clobbering

DOM clobbering happens when HTML elements with specific id or name attributes override global JavaScript variables.

<form id="settings">
  <input name="action" value="attacker-controlled">
</form>
<script>
  // Developer expects settings.action to be a string from app config
  // but settings.action is the <input> element
  fetch(settings.action)  // fetch([object HTMLInputElement]) - broken
</script>

Modern CSP prevents many clobbering attacks.

Never use bare window property access for configuration; always use explicit document.getElementById with type checking.

Prototype pollution

Prototype pollution attacks modify Object.prototype or Array.prototype, affecting every object in the runtime.

// Vulnerable deep merge
function merge(target, source) {
  for (const key of Object.keys(source)) {
    if (typeof source[key] === 'object') merge(target[key] ??= {}, source[key])
    else target[key] = source[key]
  }
}

// Attacker input:
merge({}, JSON.parse('{"__proto__": {"isAdmin": true}}'))
({}).isAdmin  // true - every object is now "admin"

lodash.merge had this vulnerability.

Validate keys against __proto__, constructor, prototype; use Object.create(null) for dictionary objects; use structuredClone for deep copying untrusted input.

Concurrency & state architecture

Race conditions in UI state

A race condition in UI happens when two async operations compete and their completion order isn't guaranteed. Classic example: the user types in a search box, two requests fire, the slower one resolves last and overwrites the correct (newer) result.

// Race condition - last fetch wins, not last query
const results = ref([])
const search = async (query: string) => {
  const data = await $fetch(`/api/search?q=${query}`)
  results.value = data  // may overwrite a newer result
}

The Vue-idiomatic fix is watch with onCleanup:

const query = ref('')
const results = ref([])

watch(query, async (q, _, onCleanup) => {
  const controller = new AbortController()
  // onCleanup runs before the next watch execution
  onCleanup(() => controller.abort())

  try {
    results.value = await $fetch(`/api/search?q=${q}`, {
      signal: controller.signal
    })
  } catch (e) {
    if (e.name !== 'AbortError') throw e
  }
})

onCleanup fires before the next watch run (when query changes again), aborting the in-flight request before a new one starts.

Tearing in concurrent UI

Tearing is a concurrent rendering artifact where different parts of the UI read the same shared mutable state at different points during a single render pass, resulting in a UI that's internally inconsistent. Component A reads the old value, rendering pauses, the store updates, Component B reads the new value - both states appear on screen simultaneously.

Vue's reactivity system is synchronous in its dependency tracking and doesn't have this problem by design. When state changes, Vue tracks which components depend on it and schedules their re-renders atomically - there's no "render pause" mid-component that could expose a torn state. It's worth knowing the term because you'll encounter it in library documentation and job interviews, even if it's not a Vue-specific problem to solve.

Scheduler priorities

Concurrent frameworks maintain a priority system for rendering tasks - some updates are more urgent than others. User input needs to feel instant; a background data sync can wait.

Vue's scheduler doesn't expose priority levels as a public API, but it has implicit ordering: watchers triggered by user interaction run before passive watchers, and component re-renders are batched and flushed via nextTick (a microtask), which means the browser gets control between ticks. For manual control, watchEffect with flush: 'post' runs after the DOM update, flush: 'sync' runs immediately. These aren't "priority" in a formal sense, but they let you control the execution order around renders.

Render waterfalls

A render waterfall is when rendering is blocked in sequence: component A renders, starts a data fetch, waits, then component B renders, starts another fetch, waits...

Component A renders -> fetch /api/user (200ms wait)
-> Component B renders -> fetch /api/posts (150ms wait)
Total: 350ms sequential loading
Solutions: hoist all data fetches to the route level (fetch in parallel with Promise.all), or use Suspense with streaming so each component's data resolves independently without blocking siblings. In Nuxt, useAsyncData with deduplication plus route-level parallel fetches prevents waterfalls.

Micro-frontend orchestration

Micro-frontends decompose a frontend application into independently deployable parts. Orchestration approaches:

  • Server-side composition: Nginx/CDN assembles HTML fragments from multiple services (SSI, Edge Side Includes). Simple but limited runtime interactivity.
  • Build-time composition: all fragments bundled together at deploy time. Defeats "independent deployment."
  • Runtime composition: Module Federation, import() from remote URLs, iframe embedding. True independence but complex dependency management.

Most organizations overestimate their need for micro-frontends and underestimate the coordination overhead. The genuine use case is large organizations with genuinely independent teams.

Finite state modeling

UI has states. Most bugs are transitions between states that weren't anticipated. Finite state machines make all possible states and transitions explicit, preventing invalid state combinations.

// Without FSM: booleans that can be in invalid combinations
const isLoading = ref(false)
const isError = ref(false)
const isSuccess = ref(false)
// isLoading && isSuccess at the same time? Possible, shouldn't be.

// With XState: all states explicit, invalid combinations impossible
const machine = createMachine({
  initial: 'idle',
  states: {
    idle:    { on: { FETCH: 'loading' } },
    loading: { on: { RESOLVE: 'success', REJECT: 'error' } },
    success: { on: { FETCH: 'loading' } },
    error:   { on: { RETRY: 'loading', GIVE_UP: 'idle' } },
  }
})

XState implements FSMs and statecharts for JavaScript. For simpler cases, a reducer pattern with Pinia achieves similar explicitness without the full XState machinery.

Event sourcing in frontend

Event sourcing stores state as a sequence of events rather than the current value. Current state is derived by replaying events from the beginning.

In frontend: instead of storing { quantity: 3 }, store [{add: 1}, {add: 1}, {add: 1}]. Undo/redo is dropping the last event and recomputing. Pinia with a custom action logger can approximate this - store a list of dispatched actions and re-run them from a snapshot to "time-travel." Git is event sourcing. Any undo history is event sourcing.

The downside: replaying many events is expensive. In practice, snapshots at regular intervals plus replay from the last snapshot keep it performant.

Optimistic UI rollback strategy

Optimistic UI applies state changes before the server confirms them, assuming success. The rollback strategy when the server fails matters more than the happy path.

The complexity: if the user makes additional changes during a pending request, naively reverting all state loses their legitimate input. The correct approach: rollback only the specific optimistic mutation, leaving subsequent changes intact.

In Nuxt, the pattern with useFetch or $fetch:

const items = ref([...serverItems])

async function addItem(item) {
  // 1. Save previous state
  const previous = [...items.value]
  // 2. Apply optimistically
  items.value.push({ ...item, _pending: true })
  try {
    const saved = await $fetch('/api/items', { method: 'POST', body: item })
    // 3. Replace optimistic entry with server-confirmed one
    items.value = items.value.map(i => i._pending ? saved : i)
  } catch {
    // 4. Rollback only this mutation
    items.value = previous
  }
}

Offline conflict resolution

When a user edits data offline and reconnects, the server may have changed while they were away. Strategies:

  • Last-write-wins: most recent timestamp wins. Simple, loses data silently.
  • First-write-wins: server rejects updates to data that changed since last sync. Client must re-fetch and re-apply.
  • Manual merge: show the user both versions. Correct but requires UI.
  • CRDTs: data structures that merge automatically without conflicts (see below).

For most apps: optimistic mutations + server-wins conflict resolution + a sync status indicator is sufficient.

CRDT basics for collaboration

CRDTs (Conflict-free Replicated Data Types) are data structures that merge automatically without coordination. Any two replicas converge to the same state when merged, regardless of operation order.

The simplest: a G-Counter (grow-only counter). Each peer has its own counter; merge is element-wise max:

// Peer A: { A: 3, B: 1 }
// Peer B: { A: 2, B: 2 }
// Merge:  { A: 3, B: 2 }  - deterministic regardless of merge order

More useful: LWW-Register (last-write-wins with vector clocks), OR-Set (handles add/remove conflicts), RGA (for text editing).

Yjs and Automerge are production-grade CRDT libraries. Yjs powers collaborative editing in Notion alternatives and code editors. The Nuxt integration pattern: Yjs + y-websocket server + a Vue binding for your content type.

Async & streams

WebRTC

WebRTC provides browser APIs for peer-to-peer audio, video, and data channels. No server required for actual data transfer - only for signaling (exchanging offers and ICE candidates).

The connection setup: both peers exchange SDP (Session Description Protocol) offers through a signaling server (WebSocket, any protocol works). STUN servers help discover public IP:port pairs; TURN servers relay traffic when P2P fails (symmetric NAT, firewalls).

RTCDataChannel is the P2P data primitive - think WebSocket but between browsers. RTCPeerConnection with media streams handles audio/video. The hard parts are NAT traversal reliability and signaling server uptime.

Backpressure in streams API

Backpressure is the mechanism by which a data consumer signals to a producer to slow down. Without it, a fast producer + slow consumer leads to unbounded buffer growth and memory exhaustion.

The WHATWG Streams API has backpressure built in. The pull method on ReadableStream is called only when the downstream consumer is ready for more data:

const readable = new ReadableStream({
  async pull(controller) {
    // Called only when downstream has consumed previous chunks
    const chunk = await getNextChunk()
    controller.enqueue(chunk)
  }
})

fetch() with streaming response bodies participates in this mechanism - reading the response body slowly applies backpressure to the HTTP connection.

AbortController

AbortController provides a signal that can cancel fetch requests, streams, and any operation that accepts a signal.

const controller = new AbortController()
setTimeout(() => controller.abort(), 5000)

try {
  const res = await fetch('/api/data', { signal: controller.signal })
} catch (e) {
  if (e.name === 'AbortError') console.log('Request cancelled')
}

AbortSignal.timeout(5000) is a shorthand for timeout-based cancellation without creating a manual controller. In Vue composables, abort in onUnmounted. In Nuxt's useAsyncData, abort is handled internally when a component unmounts or the key changes - but manual $fetch calls inside watch need explicit abort logic via onCleanup (as shown in the race conditions section above).

Streaming fetch response handling

fetch returns a Response with body: ReadableStream. You can process it incrementally rather than waiting for the full response:

const response = await fetch('/api/large-data')
const reader = response.body.getReader()
const decoder = new TextDecoder()

while (true) {
  const { done, value } = await reader.read()
  if (done) break
  const chunk = decoder.decode(value, { stream: true })
  processChunk(chunk)  // handle each chunk as it arrives
}

This is how LLM streaming responses work in chat UIs - each token arrives as a chunk. In Nuxt with $fetch (ofetch), you can access the underlying response stream for the same pattern.

Priority inversion in async code

Priority inversion is when a high-priority task is blocked by a low-priority one. In async code: a critical user interaction is queued behind background processing that shouldn't block it.

The browser's event loop doesn't inherently prioritize tasks (except microtasks over macrotasks). A setTimeout(heavyWork, 0) that runs for 200ms blocks a subsequent click handler.

Fix: break heavy work into chunks with scheduler.yield() or await new Promise(r => setTimeout(r, 0)) between chunks. This yields to the macrotask queue, giving the event loop a chance to process the pending click before continuing.

Memory & garbage collection

Browser memory leak detection

JavaScript garbage-collects objects with no remaining references. A leak is a reference you didn't intend to keep that prevents collection.

Common patterns:

  • Event listeners not removed: addEventListener without removeEventListener keeps the callback and its closure alive
  • Detached DOM nodes: elements removed from the document but still referenced
  • Growing caches: unbounded Maps or arrays without eviction
  • Closures over large objects: a small callback that captures a large scope

To detect: Chrome DevTools > Memory > Take Heap Snapshot. Snapshot before an action, perform the action, navigate away, snapshot again. Objects that grew indicate a leak. The "Detached DOM tree" filter shows detached nodes directly.

Detached DOM nodes

A detached node is a DOM element removed from the document but still referenced by JavaScript - a global variable, a closure, an event listener.

const cache: HTMLElement[] = []

function buildWidget() {
  const el = document.createElement('div')
  cache.push(el)  // el will never be GC'd even if removed from DOM
  document.body.appendChild(el)
}

function removeWidget(el: HTMLElement) {
  el.remove()
  // cache still holds reference - el is detached but not collected
}

Fix: clear references when removing elements. WeakRef and WeakMap hold references that don't prevent GC - useful for caches keyed by DOM nodes.

Garbage collection timing

JavaScript GC is not deterministic. The browser decides when to run it - typically under memory pressure. Major GC (mark-and-sweep of the entire heap) causes pauses that appear as long tasks in DevTools.

You can't force GC in production. What you can do: minimize allocation pressure. Object pooling (reusing objects instead of creating new ones each frame) reduces GC frequency. For animation code running at 60fps, avoid heap allocations inside the render loop.

structuredClone, JSON.parse(JSON.stringify()), and spread operators all allocate. In hot paths, mutate in place.

Web vitals & performance APIs

PerformanceObserver API

The programmatic way to observe performance metrics:

const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    console.log(entry.entryType, entry.name, entry.startTime, entry.duration)
  }
})
observer.observe({
  entryTypes: ['largest-contentful-paint', 'long-animation-frame', 'layout-shift']
})

Entry types include: navigation, resource, mark, measure, paint, largest-contentful-paint, layout-shift, long-task, long-animation-frame, first-input, element. This is how the web-vitals library collects real user metrics (RUM).

Long tasks API

A long task is any task that blocks the main thread for more than 50ms. It's the primary cause of input delay.

new PerformanceObserver((list) => {
  for (const task of list.getEntries()) {
    console.log('Long task:', task.duration, 'ms')
  }
}).observe({ entryTypes: ['longtask'] })

long-animation-frame (LoAF) is the successor to longtask - it tracks animation frames that took longer than 50ms and includes attribution (which script caused it), making it far more actionable for debugging.

First Input Delay (FID)

FID measured the delay between a user's first interaction (click, tap, key press) and the browser's response. Specifically: the time the browser was blocked by a long task when the input arrived.

FID is dead as a Core Web Vital. Google replaced it with INP in March 2024. FID measured only the first interaction on a page; INP measures all interactions throughout the session.

Interaction to Next Paint (INP)

INP is the Core Web Vital that replaced FID. It reports the worst interaction latency of the entire page session (with some outlier exclusion). An "interaction" is a click, tap, or keyboard press - measured from input to the next paint after all associated processing completes.

  • 🟢 Good: <200ms.
  • 🟡 Needs improvement: 200-500ms.
  • 🔴 Poor: >500ms.

Common INP culprits: long tasks in event handlers, synchronous XHR, layout thrashing triggered by interactions, large JavaScript evaluated on click. Fix: break up event handler work with scheduler.yield(), defer non-essential work to after the next paint.

Cumulative Layout Shift (CLS)

CLS measures visual instability - elements that unexpectedly move during page load. Calculated as sum of (impact fraction * distance fraction) for unexpected shifts.

  • 🟢 Good: <0.1.
  • 🔴 Poor: >0.25.

Biggest causes: images without width/height attributes, ads injected into content without reserved space, web fonts causing text reflow (FOUT), dynamically injected content above existing content.

In Nuxt: @nuxt/image automatically includes dimensions. @nuxt/fonts with Fontaine handles font CLS. For injected content, use CSS min-height on container elements to reserve space.

Largest Contentful Paint (LCP)

LCP marks the render time of the largest visible element in the viewport - typically a hero image or large heading. It's the best proxy for "when does the page feel loaded."

  • 🟢 Good: <2.5s.
  • 🔴 Poor: >4s.

LCP is blocked by: slow TTFB, render-blocking resources, slow resource download, client-side rendering. In Nuxt with SSR, LCP is typically the hero image. Strategies: fetchpriority="high" on the hero image, serve from CDN, WebP/AVIF format, <link rel="preload"> in the document head.

Rendering correctness

Deterministic rendering

Deterministic rendering means given the same state input, the render output is always identical - no randomness, no timestamp-based differences, no locale-dependent formatting unless explicitly parameterized.

Non-deterministic rendering causes hydration mismatches in SSR: the server renders with one value, the client re-renders with a different value, Vue warns and potentially re-renders the entire subtree.

Common causes in Nuxt: Math.random(), Date.now(), new Date() called in component logic, browser-only APIs (window, navigator, localStorage) accessed in server-side code.

Guard browser APIs with import.meta.client, pass server-generated values as props, use useId() for component IDs.

Idempotent UI actions

An idempotent action produces the same result regardless of how many times it's executed. Setting state to { active: true } is idempotent - clicking five times has the same result as once. A toggle is not idempotent.

Idempotency matters for: optimistic UI resends (does retrying a failed mutation cause double-updates?), Service Worker precaching (installing the same asset twice must be safe), analytics deduplication.

For mutations, using IDs and upsert semantics (PUT /resource/id, INSERT OR REPLACE) instead of append semantics makes server handlers idempotent.

Accessibility internals

Accessibility tree

The accessibility tree is a parallel structure the browser builds from the DOM, populated with semantic information: role, name, description, state. Screen readers and assistive technologies read this tree, not the DOM directly.

DOM
<button class="btn" id="save">Save</button>
Accessibility tree
  button (role)
    name: "Save" (from text content)
    focusable: true
    disabled: false
display: none and visibility: hidden remove nodes from the accessibility tree. opacity: 0 does not - the element is invisible but still announced. CSS generally doesn't affect the accessibility tree.

Inspect it in Chrome DevTools > Elements > Accessibility tab.

ARIA live regions internals

ARIA live regions tell screen readers to announce content updates without focus moving. aria-live="polite" waits for the user to finish their current interaction. aria-live="assertive" interrupts immediately.

The implementation: the browser watches for DOM changes inside live region elements and queues announcements in the screen reader's speech queue. The gotchas:

  1. You can't add aria-live to an element that already has content and expect initial announcement - only changes are announced.
  2. Initial page content is never announced via live regions - only subsequent mutations.
  3. Removing and re-adding a live region element with different content triggers announcements; modifying content in place also triggers.

For Nuxt SPA navigation: announce page title changes via a visually-hidden live region, because screen readers don't detect route changes automatically.

Pointer events

The Pointer Events API unifies mouse, touch, and stylus input into a single event model. pointerdown, pointermove, pointerup, pointercancel work for all input types.

el.addEventListener('pointerdown', (e) => {
  e.pointerId      // unique ID per active pointer (multitouch support)
  e.pointerType    // 'mouse' | 'touch' | 'pen'
  e.pressure       // 0-1, stylus pressure
  e.tiltX, e.tiltY // stylus angle
})

touch-action CSS controls which pointer events get handled natively vs passed to JavaScript. touch-action: none means JS handles everything - needed for custom drag-and-drop. touch-action: pan-y means the browser handles vertical scroll; JS gets horizontal events. Forgetting this causes 300ms tap delays on mobile or scroll interference in drag interactions.

setPointerCapture(pointerId) keeps pointer events firing on an element even when the pointer moves outside it - essential for drag handles where the user moves faster than the hit target.


Most frontend complexity exists because browsers evolved over 30 years, JavaScript was designed in 10 days, and the two have been fighting for alignment ever since. The developers who write genuinely fast, reliable, accessible software aren't the ones who know every API by heart - they're the ones with accurate mental models of what's happening below their code.

This is a starting point for building those models tho.

Continue Reading