Nuxt 3 проект электронной коммерции, развёрнутый на Cloudflare Pages: начальный LCP 10.2 секунды, bounce rate на мобильных %18. Проблема: RTT на Google Fonts 840 мс, клиентская гидратация блокирует 3.1 секунды, отсутствие content-visibility на некоторых выше линии сгиба изображениях. После трёх недель итерации: LCP 1.9 секунды, TBT 190 мс, bounce rate %11. Решение: стратегия самостоятельных шрифтов, timing гидратации, CSS containment, edge-уровневое кэширование через Cloudflare Workers. Эта статья рассказывает цифрами, как это было сделано.

Self-Hosted Шрифты: 840 мс RTT исчез

В первой версии использовали модуль @nuxtjs/google-fonts. На водопаде Network в Chrome DevTools видна последовательность: HTML парсинг → fetch CSS Google Fonts (280 мс) → загрузка woff2 файлов (3 варианта, каждый 180–240 мс). Общие 840 мс сетевых издержек, отодвигающие LCP на 2.4 секунды назад.

Решение: self-hosting из fontsource. Добавили @fontsource/inter в package.json, импорт CSS в nuxt.config.ts:

export default defineNuxtConfig({
  css: [
    '@fontsource/inter/400.css',
    '@fontsource/inter/600.css'
  ],
  vite: {
    build: {
      rollupOptions: {
        output: {
          assetFileNames: 'assets/fonts/[name]-[hash][extname]'
        }
      }
    }
  }
})

Файлы шрифтов обслуживаются под префиксом /_nuxt/ на Cloudflare Pages, тот же origin — RTT 18 мс. Для preload в head management app.vue:

<script setup>
useHead({
  link: [
    { rel: 'preload', href: '/_nuxt/inter-400.woff2', as: 'font', type: 'font/woff2', crossorigin: 'anonymous' }
  ]
})
</script>

Результат: время загрузки шрифта 840 мс → 62 мс. LCP выгадал 2.4 секунды, упал до 7.8 секунды.

Ленивая Гидратация: Hero Component потерял 1.9с Блокирования

Hero банер: слайдер, наведения анимация, intersection observer. На стадии клиентской гидратации добавляет 1.9 секунды TBT (Total Blocking Time) — главный поток заблокирован. Пользователь пытается скролл, UI не отвечает.

Использовали nuxt/lazy-hydrate экспериментальный функционал из Nuxt 3.5+. Привязали компонент Hero к срабатыванию гидратации вручную:

<template>
  <LazyHydrate when-visible>
    <HeroBanner :slides="heroSlides" />
  </LazyHydrate>
</template>

<script setup>
import { LazyHydrate } from '#components'
const heroSlides = await useFetch('/api/hero-slides')
</script>

when-visible: компонент гидратируется, когда попадает в viewport. На начальном рендере приходит HTML, интерактивность нет — пользователь всё равно не может скролл, так что нет проблемы. Когда компонент входит в viewport, гидратация стартует, 1.9 секунды блокирования больше не в критическом пути.

TBT 3.1s → 1.2s. INP (Interaction to Next Paint) метрика 520 мс → 180 мс. Пользователь может начать скролл на 2.3 секунды раньше.

Content-Visibility для Above-the-Fold Контента

Три карточки товара под hero: каждая 240px высоты, видны в первом viewport'е. Браузер вычисляет layout, paint занимает 340 мс. Добавили CSS content-visibility: auto, сигнализируя браузеру "пропусти layout вне viewport'а":

.product-card {
  content-visibility: auto;
  contain-intrinsic-size: 240px;
}

contain-intrinsic-size: браузер предполагает размер до layout, прыгание полосы прокрутки предотвращается. First Paint 340 мс → 180 мс, CLS (Cumulative Layout Shift) 0.18 → 0.04.

Edge Кэширование: Cloudflare Workers с Кэшем HTML

Nuxt SSR рендер работает в Cloudflare Pages Functions (V8 изолят). На каждый request запускается Vue SSR pipeline, среднее TTFB (Time to First Byte) 420 мс. Динамического контента нет — списки товаров, посты блога одни и те же, нет сегментации пользователей.

Решение: Cloudflare Workers middleware с кэшем HTML. В файле functions/_middleware.ts:

export const onRequest: PagesFunction = async (context) => {
  const cache = caches.default
  const cacheKey = new Request(context.request.url, context.request)
  
  let response = await cache.match(cacheKey)
  
  if (!response) {
    response = await context.next()
    
    if (response.status === 200) {
      const headers = new Headers(response.headers)
      headers.set('Cache-Control', 'public, max-age=3600, s-maxage=7200')
      const cachedResponse = new Response(response.body, {
        status: response.status,
        headers
      })
      context.waitUntil(cache.put(cacheKey, cachedResponse.clone()))
    }
  }
  
  return response
}

caches.default: Cloudflare edge cache API. max-age=3600 для browser cache, s-maxage=7200 для edge cache. Первый request выполняет SSR рендер (420 мс TTFB), последующие возвращаются с edge'а (28 мс TTFB).

Среднее TTFB 420 мс → 54 мс. Для LCP критично: HTML приходит быстрее, парсер стартует раньше, preload шрифтов срабатывает раньше.

Оптимизация Изображений: Cloudflare Images Transform

Изображения товаров, в среднем 1.8 МБ, JPEG формат. LCP элемент — первое изображение в hero слайдере, загрузка 1.8 МБ занимает 3.2 секунды. Раньше сервили с собственного origin вместо Cloudflare Images.

Перешли на Cloudflare Images: автоматическое конвертирование в WebP, responsive sizing, edge cache. Интеграция @nuxt/image в nuxt.config.ts:

export default defineNuxtConfig({
  image: {
    cloudflare: {
      baseURL: 'https://imagedelivery.net/YOUR_ACCOUNT_HASH'
    },
    screens: {
      xs: 320,
      sm: 640,
      md: 768,
      lg: 1024,
      xl: 1280
    }
  }
})

В компоненте:

<NuxtImg
  provider="cloudflare"
  src="/product-hero.jpg"
  sizes="sm:640px md:768px lg:1024px"
  format="webp"
  quality="85"
  loading="eager"
  fetchpriority="high"
/>

fetchpriority="high": браузеру, это изображение приоритетно. loading="eager": без ленивой загрузки, fetch сразу. Для hero логично. 1.8 МБ JPEG → 420 КБ WebP, вклад LCP с 3.2s упал до 0.8s.

Эта правка шла параллельно с обсуждениями performance budget в процессе UI/UX дизайна — уменьшили размер файла на %76 без снижения качества визуала.

Runtime Телеметрия: Валидация на Реальных Данных Пользователей

Lab data (Lighthouse, WebPageTest) показывает LCP 1.9s. А что в RUM (Real User Monitoring)? Cloudflare Web Analytics + Google Analytics 4 с custom event'ами отслеживаем:

// plugins/web-vitals.client.ts
import { onLCP, onINP, onCLS } from 'web-vitals'

export default defineNuxtPlugin(() => {
  onLCP((metric) => {
    if (window.gtag) {
      gtag('event', 'web_vitals', {
        event_category: 'Web Vitals',
        event_label: 'LCP',
        value: Math.round(metric.value),
        metric_id: metric.id,
        non_interaction: true
      })
    }
  })
  
  // То же для INP, CLS
})

За 14 дней данных: P75 LCP 2.1s (в lab'е 1.9s), P75 INP 220 мс (в lab'е 180 мс). Разница lab-RUM %10 — приемлемо. На мобильном 4G пользователи: LCP 2.4s, на WiFi 1.8s. Когда профиль сети вариативен, edge caching становится ещё критичнее.

Трейдофф: Время Сборки и Developer Experience

Self-hosted шрифты добавляют +8s к npm install. Модуль @nuxt/image на dev server'е: первый старт с 3.2s до 4.1s. Ленивая гидратация сложнее отлаживать — нужны console log'и у границ гидратации, отслеживание timing'а вручную.

Invalidation кэша Cloudflare Workers: когда приходит обновление товара, нужно очистить edge cache через Cloudflare API. Добавили в CI/CD pipeline:

curl -X POST "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/purge_cache" \
  -H "Authorization: Bearer $CF_API_TOKEN" \
  -H "Content-Type: application/json" \
  --data '{"purge_everything":true}'

Deployment +12s. Трейдофф: runtime performance выигрыш %40 drop в bounce rate стоит ли +12s deploy? Для нашего проекта — да.

Итоги Оптимизации

МетрикаДоПослеВыигрыш
LCP (P75)10.2s1.9s%81
TBT3.1s190 мс%94
CLS0.180.04%78
TTFB420 мс54 мс%87
Bounce Rate%18%11%39

Conversion rate с %2.1 до %2.8 (+%33). Цифры — корреляция; без A/B тестирования, изменения цены, кампаний других нет. Можно разумно атрибутировать улучшениям performance.

Web performance не просто про "быстрый сайт" — напрямую связано с bounce rate, конверсией, выручкой. 10-секундный LCP теряет пользователей, 2 секунды повышает шансы конверсии. Self-hosted шрифты, ленивая гидратация, edge caching — в современных frontend stack'ах это обязательные шаги. Cloudflare Pages + Nuxt 3 облегчают эту работу, но дефолтный конфиг недостаточен. Требуется ручная настройка.