Cloudflare Pages + Nuxt 3 обещают edge-кеширование и zero-config деплой, но для Core Web Vitals этого недостаточно. В боевом e-commerce проекте LCP был 10.2 секунды, TBT — 2190 мс. Google Fonts, client-side гидратация, глобальный CSS и синхронный JavaScript блокировали рендеринг. С помощью self-hosted шрифтов, ленивой гидратации, CSS свойства content-visibility и edge-кеш стратегии мы снизили LCP до 2.1 сек, TBT — до 180 мс. Рассказываем реализацию и trade-off'ы.

Google Fonts блокирует рендеринг: потеря 3.8s

Шрифты, подключенные через @import или <link> с Google Fonts CDN, блокируют рендеринг страницы. FOIT (Flash of Invisible Text) и 3+ round-trip'а добавляют латенси. Lighthouse показал "Eliminate render-blocking resources" с потерей 3.8 сек.

Решение: self-hosted шрифты. Использовали пакет @fontsource/inter, поместили Woff2 файлы в public/fonts. В Nuxt config добавили preload:

// nuxt.config.ts
export default defineNuxtConfig({
  app: {
    head: {
      link: [
        {
          rel: 'preload',
          as: 'font',
          type: 'font/woff2',
          href: '/fonts/inter-latin-400-normal.woff2',
          crossorigin: 'anonymous'
        },
        {
          rel: 'preload',
          as: 'font',
          type: 'font/woff2',
          href: '/fonts/inter-latin-600-normal.woff2',
          crossorigin: 'anonymous'
        }
      ]
    }
  }
})

В CSS определили только используемые начертания через @font-face:

/* assets/css/fonts.css */
@font-face {
  font-family: 'Inter';
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url('/fonts/inter-latin-400-normal.woff2') format('woff2');
}

@font-face {
  font-family: 'Inter';
  font-style: normal;
  font-weight: 600;
  font-display: swap;
  src: url('/fonts/inter-latin-600-normal.woff2') format('woff2');
}

С font-display: swap приняли trade-off — вместо невидимого текста показывается системный шрифт, затем происходит замена. LCP упал до 6.4 сек. Прирост бандла — 72 KB (Woff2 compressed), но экономия 3.8 сек оправдана.

Client-side гидратация: TBT 2190ms

Nuxt 3 по умолчанию гидратирует все компоненты на клиенте. В app.vue было 40+ компонентов, Pinia store, composable'ы и третьесторонние библиотеки (Swiper, vue-gtag) блокировали main thread. Performance tab показал 8 "Long Tasks", самая долгая — 1240 мс.

Ленивая гидратация с приоритизацией

Компоненты below-the-fold гидратируем лениво. После добавления @nuxtjs/web-vitals и анализа критического пути приоритизировали компоненты:

<!-- pages/index.vue -->
<template>
  <div>
    <!-- Above-the-fold: гидратируем сразу -->
    <HeroSection />
    <ProductGrid :products="products" />

    <!-- Below-the-fold: ленивая гидратация -->
    <LazyFooter v-if="mounted" />
    <LazyNewsletterForm v-if="mounted" />
    <client-only>
      <LazyReviewCarousel :reviews="reviews" />
    </client-only>
  </div>
</template>

<script setup lang="ts">
const mounted = ref(false)

onMounted(() => {
  requestIdleCallback(() => {
    mounted.value = true
  })
})
</script>

Wrapper <client-only> исключил Swiper и другие DOM-dependent библиотеки из SSR. requestIdleCallback запускает гидратацию, когда main thread свободен. TBT упал до 840 мс.

Bundle splitting и code splitting

С помощью vite-plugin-inspect анализировали бандл. Swiper занимал 168 KB minified, но использовался только в карусели отзывов. Вместо динамического импорта сначала уменьшили использование — оставили только модуль Navigation, убрали Virtual и Autoplay:

// composables/useSwiper.ts
import { Navigation } from 'swiper/modules'
import 'swiper/css'
import 'swiper/css/navigation'

export const useSwiperModules = () => [Navigation]

Бандл сократился с 168 KB до 42 KB. <LazyReviewCarousel> уже ленивый, поэтому не попал в основной бандл.

Content-visibility: сокращение периода рендеринга

Product grid содержит 48 карточек, каждая — image + title + price + button. При инициальном рендере браузер одновременно считает layout для всех 48 карточек. CSS свойство content-visibility: auto исключило below-the-fold карточки из рендеринга:

/* components/ProductCard.vue */
.product-card {
  content-visibility: auto;
  contain-intrinsic-size: 320px 420px;
}

contain-intrinsic-size указывает браузеру placeholder размер карточки, избегая проблем со scroll position. LCP упал с 6.4 до 3.9 сек. Trade-off: карточки вне viewport рендерятся при скролле, но добавил лишь 12 мс к INP (приемлемо).

Edge кеширование: TTFB 1.2s → 40ms

Cloudflare Pages по умолчанию не кеширует HTML, каждый запрос идет на origin. SSR ответ занимает ~1200 мс (API + рендеринг). Через файл _headers включили edge кеширование:

# public/_headers
/*
  Cache-Control: public, max-age=0, s-maxage=600, stale-while-revalidate=86400
  X-Frame-Options: DENY
  X-Content-Type-Options: nosniff

s-maxage=600 — Cloudflare edge кеширует на 10 минут. stale-while-revalidate=86400 — при истечении cache показывает старую версию, в фоне обновляет. TTFB упал до 40 мс (edge hit). Origin request только при cache miss или revalidation.

ISR с гибридным рендерингом

Для product pages использовали Incremental Static Regeneration. В Nuxt через routeRules:

// nuxt.config.ts
export default defineNuxtConfig({
  routeRules: {
    '/products/**': { 
      swr: 600,  // 10 минут
      prerender: false
    },
    '/': { 
      swr: 300   // 5 минут
    }
  }
})

Первый запрос — SSR, затем edge кеш. Для обновления стока используем webhook для manual purge:

// server/api/purge-cache.post.ts
export default defineEventHandler(async (event) => {
  const { productId } = await readBody(event)
  
  await fetch(`https://api.cloudflare.com/client/v4/zones/${process.env.CF_ZONE_ID}/purge_cache`, {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${process.env.CF_API_TOKEN}`,
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({
      files: [`https://example.com/products/${productId}`]
    })
  })
  
  return { success: true }
})

Сравнение бенчмарков

МетрикаДоПослеИзменение
LCP10.2s2.1s-79%
TBT2190ms180ms-92%
TTFB1200ms40ms-97%
FCP4.8s1.2s-75%
CLS0.180.02-89%
Bundle (initial)284 KB186 KB-34%

Тест: Chrome 121, 4G throttling, Lighthouse CI. Среднее 10 прогонов. LCP теперь ниже 2.5 сек — Google "Good" threshold достигнут.

Trade-off'ы и внимание

Self-hosted шрифты теряют глобальную CDN сеть, но Cloudflare Pages уже edge-hosted. С Woff2 дополнительная латенси минимальна. Ленивая гидратация снижает initial interactivity — below-the-fold компоненты становятся интерактивными после mounted. Добавьте в аналитику метрику "time to interactive below fold".

content-visibility не поддерживается в Safari до 17.4, используйте @supports. Edge кеширование конфликтует с персонализацией — разделите через Cache-Control: private или client-side рендеринг для корзины и авторизации.

ISR webhook purge — ручной процесс, интегрируйте с inventory системой. Для критичных страниц (checkout, payment) отключите ISR.

Масштабируемость с Composable архитектурой

Эти оптимизации тестировали на Headless Commerce — Nuxt 3 frontend, Shopify Storefront API backend. Паттерн работает в Next.js + Hydrogen и Remix. Edge-кеш стратегия framework-agnostic, расширяется через Cloudflare Workers KV или Vercel Edge Config. Добавьте RUM (Real User Monitoring) — Cloudflare Web Analytics или Sentry Performance.

LCP 2.1 сек — Google "Good", но на мобилях 4G ниже требует тестирования. Progressive enhancement — критичный контент должен рендериться без JavaScript. Используйте Nuxt <NoScript> компонент.

SLUG: nuxt-3-cloudflare-pages-lcp-optimizatsiya