« Back to work

Passing Core Web Vitals: the framework-agnostic rules, applied to Vue + SSR

The four metrics crawlers actually grade you on (LCP, FCP, CLS, INP), the rules that move them in any architecture, and how those rules look in a Vue + SSR + Quasar setup specifically. With the instrumentation I use to measure each.

The four numbers that matter

Core Web Vitals are four numbers Google publishes thresholds for, used in search ranking signals, displayed in the Lighthouse audit panel and in the CrUX report Search Console surfaces. They're the only performance numbers most teams ever look at.

They are:

LCP (Largest Contentful Paint): how long until the largest visible element finishes rendering. Threshold: 2.5 s for green. Practically: how long until your hero is on screen.

FCP (First Contentful Paint): how long until ANY visible content paints. Threshold: 1.8 s for green. Practically: how long until the user sees the page exists at all.

CLS (Cumulative Layout Shift): total amount of visible content that jumps around after first paint. Threshold: 0.1 for green. Practically: did the page settle or did the user click something that moved.

INP (Interaction to Next Paint): longest interaction-to-paint latency the user experienced. Threshold: 200 ms for green. Practically: when the user clicks, how long until something visibly changes.

The framework you ship in does not matter to any of these numbers. The four metrics measure the user's perception of your network, render, and interactivity budgets; they don't care whether the bytes were assembled by Vue, React, Svelte, or hand-rolled HTML. What changes between frameworks is the shape of the levers you pull. The levers themselves are the same.

The framework-agnostic rules

Eight rules cover ~95% of the practical work. None of them mention a framework name.

1. Critical CSS belongs in the HTML response

The browser cannot paint until it has the styles for the first viewport. If those styles arrive in a separate CSS file, you've added a network round trip to FCP. Inline the above-the-fold CSS into the HTML response and the first paint can happen before the rest of the bundle arrives. The remaining styles ship as a deferred chunk that loads after the page is interactive.

2. Webfonts belong inline (or self-hosted with preload)

Fonts cause a chicken-and-egg problem: the browser doesn't fetch a font until it sees a CSS rule that needs it, but the CSS rule is in the deferred bundle that ships after the first paint. The result is FOUT or FOIT depending on font-display. Either inline the font bytes directly into the critical CSS as base64 (works for small display fonts) or self-host and use <link rel="preload"> in the document head to overlap the font fetch with the HTML parse.

3. Images must reserve their space

Any image without explicit width/height attributes (or aspect-ratio in CSS) reserves zero layout space before it loads. When it does load, the surrounding content shifts. Every shifted pixel hits CLS. Set width and height on every <img>, even if the visual size is controlled by CSS — the attributes are what compute the aspect-ratio reservation.

4. The hero element should not depend on JavaScript

The largest contentful paint is usually the hero: a banner image, the page headline, a card. If that element is rendered by JavaScript that runs after hydration, your LCP waits for the JS bundle to download, parse, and execute. Render the hero in the initial HTML response. Hydrate it afterward.

5. Defer everything that isn't your hero

The 80/20 rule is brutal: 80% of the JS most apps ship has no impact on the first paint. Heavy widgets, analytics, chat bubbles, tracking pixels, decorative animations — all of them can wait. Use requestIdleCallback with a fallback timeout to schedule the work after the browser reports idle, which is guaranteed to be after LCP. Async-import the components themselves so their JS chunks split out of the critical bundle.

function scheduleIdle(cb) {
  if (typeof window === 'undefined') return
  if ('requestIdleCallback' in window) {
    window.requestIdleCallback(cb, { timeout: 2000 })
  } else {
    setTimeout(cb, 800)
  }
}

onMounted(() => {
  scheduleIdle(() => { isIdle.value = true })
})

That pattern, applied uniformly across every non-essential element, is the single biggest LCP win available without architectural changes.

6. Long tasks must be broken up

INP is dominated by main-thread blocking. Any synchronous task longer than 50 ms blocks user input. Hydration of a large Vue / React tree is often a 200-500 ms task on mid-range mobile, and it runs at the worst possible moment: right when the user might be trying to interact. Break it up: defer non-critical hydration, split components into smaller chunks that hydrate independently, schedule heavy init off the critical path.

7. Cache aggressively, invalidate by hash

Repeat visits should not pay the same network cost as first visits. Static assets (CSS, JS, fonts, images) get Cache-Control: public, max-age=31536000, immutable and the URL is hashed by content. The HTML response is cached for short periods or revalidated on every request, but the assets it points at are cached forever. Modern bundlers do this automatically; the work is making sure your CDN respects the headers.

8. Measure on the device the user has, not yours

Local Lighthouse runs default to a 4x CPU throttle and a slow-4G network. That's roughly representative of a 2020-era mid-range Android over a typical urban LTE connection. Don't disable the throttle. Don't run audits on a desktop unthrottled and ship. Field data (real-user monitoring) is the only ground truth; CrUX in Search Console is the summary Google actually grades you on.

Applied: Vue 3 + SSR + Quasar

The rules don't change. The application does. Here's what each rule looks like specifically in a Vue 3 + Quasar SSR stack, which is what this site runs on.

Rule 1: critical CSS in the SSR response

Quasar's SSR mode renders Vue components to HTML on the server. The styles that come with each component normally ship as a separate CSS file. To inline them, I extract any <style critical> block from each SFC at build time, aggregate them per-route, and inject the aggregated payload directly into the streamed HTML response via the SSR middleware. The non-critical styles still load as a deferred chunk for the rest of the page.

Implementation details and the gotchas are in the critical-css-vue-vite write-up.

Rule 2: inline fonts via the same critical pipeline

Pixel display fonts (Jersey 10) are small enough to inline as base64 directly into the critical-CSS block. A second Vite plugin reads the woff2 files at build time and emits the @font-face rules into the same <head> the critical CSS lives in. First paint has the font, no swap.

Details: inline-fonts-vite.

Rule 3: hero rendered in initial HTML

The homepage hero on this site is a faux-CRT TV bezel with a headline inside. It's an entirely server-renderable component: pure DOM, no animation that depends on JS to render the initial frame. Quasar SSR streams it as part of the first HTML response, and LCP fires on its headline text well before any JS arrives. Decorative elements (sparkles, swimming Cheep Cheep sprites, the snake game in the nav) mount after idle.

Rule 4: idle-gating site-wide

The pattern from the agnostic section applies uniformly. The decorative TetrisBackground on /contact, the PxSkyScene background, every per-card scene animation: each is async-imported and gated behind an isIdle reactive ref that flips true after requestIdleCallback fires.

const TetrisBackground = defineAsyncComponent(() =>
  import('src/components/TetrisBackground.vue')
)
const PxSkyScene = defineAsyncComponent(() =>
  import('src/components/px/PxSkyScene.vue')
)

const isIdle = ref(false)
onMounted(() => {
  scheduleIdle(() => { isIdle.value = true })
})

In the template, each component renders behind v-if="isIdle". JS chunks split, idle gate fires, components mount. None of it competes with LCP.

Rule 5: per-route CSS chunks

Vite + Quasar already split CSS by route. The preFetch hook on each route can preload the chunk while the user hovers a link, but the default split is enough for most pages. The work is making sure no route imports a giant shared bundle that defeats the split.

Rule 6: hydration breakup

Vue 3's hydration is a single tree walk by default. For a large page that can be a 100-300 ms task. Two levers help: async-importing big components splits them out of the main hydration bundle, and Vue's defineAsyncComponent lets you mark sub-trees as "hydrate later". Pair with the idle gate above and you've decoupled the heavy parts of the page from the moment the user wants to interact.

Rule 7: cookie-based theme so SSR isn't generic

One Vue-specific gotcha: SSR renders without knowing the user's theme. If your styles depend on a data-theme attribute on <html>, the server-rendered HTML will have the wrong theme until JavaScript hydrates and corrects it, which causes a visible flash and a CLS hit.

The fix: persist the theme in a cookie. Read the cookie in the SSR middleware before rendering. Set the data-theme attribute on the HTML root in the response. The first paint already has the right palette; no flash, no shift.

Rule 8: real-user monitoring

Three free options that don't slow the page down:

web-vitals from Google. ~1 kB. Subscribe to the four metrics and ship them to your analytics endpoint. Honest field data, full control.

Cloudflare Web Analytics. Zero-config if you're already on Cloudflare. CrUX-style summaries with no cookie banner needed.

Google Search Console. The Core Web Vitals report shows the same field data Google uses for ranking, aggregated across the last 28 days. The slowest signal but the one that actually matters for SEO.

What I'd do first on a slow Vue + SSR app

If someone hands me a Vue + SSR site that's failing CWV, the order of operations is roughly always the same:

1. Lighthouse trace, mobile, throttled. Note which metric is red. LCP and FCP usually go red together; CLS is a separate axis; INP shows up in field data not lab data.

2. If FCP / LCP are red, look at the network waterfall. Usually one of: a render-blocking CSS chunk, a font request, a giant hero JS bundle, or all three. Critical CSS plus inline fonts plus idle-gating fix this in a day.

3. If CLS is red, look at the layout shift events in Lighthouse. Usually images without dimensions, fonts swapping (FOUT), or late-injected ads. Set dimensions, swap to inline fonts, reserve ad slots.

4. If INP is red, profile the main thread during interaction. Usually one big handler doing too much sync work, or hydration colliding with the first user click. Break up the handler, defer hydration, schedule the heavy bit off the critical path.

5. Re-run, compare, iterate.

Wrap-up

The framework changes the syntax. The rules don't. Inline critical styles, defer everything below the fold, render the hero in HTML not JS, reserve image space, break up long tasks, cache by hash, measure on real devices. Eight sentences, ~95% of the practical work, every framework, every architecture.

The site you're reading uses every rule above and lands at green-zone CWV across the board. The plumbing is open: the critical-CSS plugin, the inline-fonts plugin, and the SSR middleware are all in the repo. If anything in here would help on a project you're shipping, mail's open via the contact page.