Когда ваш фронтенд перерастает рамки обычного корпоративного сайта и превращается в изоморфный медиа-комбайн (с реактивными движками, фоновой синхронизацией вкладок, подкастами и интернет-радио), метрики Google Lighthouse начинают стремительно краснеть.
В этом кейсе мы разберем, как нам удалось совершить тектонический сдвиг производительности в legacy-стеке Next.js 11 (на базе Webpack 5), сократив TBT (Total Blocking Time) более чем в 3.5 раза, а LCP (Largest Contentful Paint) — на рекордные 15.6 секунд.
Хронология штурма: Срез метрик PageSpeed Insights
| Метрика | До оптимизации | После выноса трекеров | После интеграции next/image | Шаг 3 (Lazy Mount + Sharp) | Финал + Query Изоляция 🚀 |
|---|---|---|---|---|---|
| First Contentful Paint (FCP) | 2.6 сек | 2.6 сек | 2.6 сек | 2.6 сек | 2.7 сек 🟡 |
| Total Blocking Time (TBT) | 720 мс | 390 мс | 280 мс | 220 мс | 150 мс 🟢 (Идеал) |
| Largest Contentful Paint (LCP) | 14.3 сек | 20.8 сек | 17.8 сек | 8.4 сек | 6.6 сек 🟡 (Ускорение в 2.2 раза) |
| Speed Index (SI) | 8.8 сек | 8.8 сек | 7.9 сек | 8.1 сек | 5.5 сек 🟢 |
| Cumulative Layout Shift (CLS) | 0.12 | 0 | 0 | 0.01 | 0.01 🟢 |
Этап 1. Разгрузка Main Thread: Выносим Google Analytics в инхаус Web Worker
Проблема
Тяжелый сторонний скрипт gtag.js инициализируется в основном потоке (Main Thread) прямо во время гидратации React-дерева. Процессор намертво зависает, парся тонны чужого JS-кода. Метрика TBT улетает в красную зону (720 мс).
Решение
Мы полностью отказались от официального скрипта gtag.js в _document.tsx. Вместо тяжелых сторонних библиотек (вроде Partytown, которые требуют сложных деклараций типов на старых версиях Next) мы написали легковесное инхаус-решение на базе нативного Web Worker и REST API (Google Measurement Protocol для GA4).
В основном потоке (Event Bus паттерн): Переписали утилиту аналитики. Теперь функции
pageview()иevent()не дергают глобальныйwindow.gtag, а просто шлют легковесные Custom Events:const dispatchToWorker = (eventType: 'pageview' | 'event', payload: any) => { if (typeof window === 'undefined') return; window.dispatchEvent(new CustomEvent('blog_analytics_event', { detail: { type: eventType, payload } })); };В корневом
_app.tsx: Повесили один сквозной слушатель, который транслирует пакеты данных в воркер черезpostMessage(затраты процессора <0.05 мс).Внутри
public/analytics-worker.js: В фоновом потоке воркер принимаетGA_TRACKING_IDна лету (командаinit), динамически формирует валидный JSON по спецификации GA4 и шлет асинхронныеPOST-запросы.Для сборки эндпоинта используется встроенный класс
URL(что исключает ошибки ручной склейки строк), а тело запроса упаковывается вBlobс типомtext/plain— это позволяет обойти префлайт-запросыOPTIONSв режимеno-cors, сохраняя при этом валидный JSON для бэкенда Google Analytics 4:// Сборка URL с обязательным api_secret из админки GA4 const url = new URL('https://google-analytics.com/mp/collect'); url.searchParams.set('measurement_id', currentGaId); url.searchParams.set('api_secret', API_SECRET); // Упаковка JSON в текстовый Blob для корректной отправки в no-cors const blob = new Blob([JSON.stringify(bodyObject)], { type: 'text/plain;charset=UTF-8' }); fetch(url.toString(), { method: 'POST', mode: 'no-cors', body: blob });
Результат: Скачивание и парсинг аналитики больше не блокируют интерфейс. TBT мгновенно рухнул до 390 мс.
Простыми словами: в чём была суть нашего «инхаус» решения?
Обычно разработчики для вынесения тяжелых скриптов аналитики (Google Analytics / Яндекс.Метрика) в Web Worker берут мощные, сложные сторонние библиотеки — например, Partytown.
Библиотека Partytown устроена как огромный комбайн: она на лету перехватывает все обращения скриптов к объектам
window,documentиlocalStorage, создавая для воркера тотальную «иллюзию» того, что он находится на обычной странице. Минус такого подхода — огромный размер самой библиотеки, капризная настройка прокси-серверов в Nginx и вечные проблемы с TypeScript-типами (any), с которыми мы как раз столкнулись
Наш «Инхаус веб-воркер» — это радикально противоположный, изящный и легкий подход. Мы сказали: «Зачем нам тащить мегабайты чужого кода для эмуляции всего браузера, если нам от аналитики нужно только две вещи: трекать просмотр страниц и клики по кнопкам?».
Мы написали своё собственное (инхаус) микро-решение всего из двух файлов:
- Скрипт-диспетчер на клиенте (
_app.tsx): Он просто ловит кастомные события кликов в React-компонентах и пересылает их в воркер одной нативной строчкойworker.postMessage(...). - Фоновый файл-воркер (
public/analytics-worker.js): Этот изолированный файл работает в фоне. Внутри него нет объектовwindowилиdocument, но они ему и не нужны! Он просто принимает голые данные (payload) и за 1-2 миллисекунды отправляет их на серверы Google через легкий сетевой HTTP-запрос по официальному REST-протоколу (Measurement Protocol).
Почему инхаус-воркер — это ультимативное решение для Lighthouse?
- Main Thread свободен на 100%: Тяжелый код аналитики вообще не скачивается и не парсится основным процессором вашего компьютера на старте, что мгновенно сбивает метрику TBT (Total Blocking Time).
- Минимум кода (Zero-dependency): Вы не добавили в проект ни одной сторонней зависимости, избавились от проблем с типами, а ваш JS-бандл остался кристально чистым и легким.
- Полный контроль: Вы сами определяете структуру отправляемых данных и логику работы фонового потока.
Инхаус веб-воркер — это синоним кастомного, ультра-оптимизированного фонового скрипта, написанного вручную «без капли лишнего жира» под конкретную задачу.
Этап 2. Разгон обложек статей через next/image
Проблема
Главный широкоформатный баннер статьи рендерился через инлайновый CSS-стиль background: url(...). Браузер замечает такую картинку слишком поздно (низкий сетевой приоритет), она летит по сети без сжатия в исходном огромном 4K разрешении, задирая LCP до страшных 20.8 секунд.
Решение
Перевели обложку на компонент Image от Next.js. Но в Next.js 11 еще не было пропса fill (спецификация Next 13+), поэтому мы применили пуленепробиваемый адаптивный паттерн layout="fill" и objectFit="cover".
Обязательно выставляем флаг priority! Он генерирует тег <link rel="preload"> прямо в <head> документа, заставляя браузер скачивать баннер в первом сетевом пакете параллельно с JS-скриптами.
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, zIndex: 0 }}>
<Image
src={article.bg?.src}
alt={article.original.title}
layout="fill"
objectFit="cover"
priority
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 100vw, 1200px"
/>
</div>Результат: Картинка начинает качаться мгновенно. LCP снизился до 17.8 секунд.
Этап 3. Борьба со штормом гидратации и каскадным фризом
Проблема
Как только мы разгрузили сеть, мы внедрили динамические ленивые импорты (next/dynamic с флагом { ssr: false }) для тяжелых виджетов навигации и лайтбокса, которые лежат ниже первого экрана.
Но в Next.js 11 это вызвало каскадный сбой регидратации (Hydration Mismatch Storm). Сервер присылал HTML без виджетов, а клиентский React 17 на первой секунде начинал судорожно «вклеивать» их в DOM, вызывая жесткие микрофризы процессора. TBT подскочил обратно до 770 мс. Из-за этого React принудительно стирал (remount) и заново создавал тег нашей обложки, сбрасывая её src буфер, из-за чего картинка на десктопе периодически превращалась в белый экран.
Решение
- Паттерн Lazy Mounting (Отложенное монтирование): Мы изолировали тяжелые виджеты с помощью флага
isMountedвнутриuseEffect. React вообще пропускает их в первом кадре, давая гидратации основного текста завершиться за долю миллисекунды:const [isMounted, setIsMounted] = useState(false); useEffect(() => { setIsMounted(true); }, []); return ( <> {isMounted && <DynamicCollapsibleQuickNav />} </> ); - Атомарная Key-защита баннера: Чтобы React при регидратации виджетов не смел стирать и сбрасывать буфер нашей обложки, мы «приварили» контейнер картинки к DOM-дереву с помощью уникального статичного ключа:
<div key={`hero-banner-image-${article.slug}`} style={{ position: 'absolute' }}>
Результат: Триада гидратации полностью стабилизирована. TBT упал до рекордных 220 мс, а LCP сократился в два раза — до 8.4 секунд!
Этап 4. Уничтожение петель Loopback и разгон Sharp на сервере
Проблема (Выявленная через curl)
Глубокий анализ заголовков показал две критические проблемы:
- Из-за
layout="fill"на больших мониторах браузер запрашивал у сервера картинку в максимальном 4K разрешении (w=3840). Высокопроизводительный бинарникsharpна сервере Node.js пытался выделить огромный буфер памяти под матрицу пикселей, вызывая Out-of-Memory краши VPS-сервера или зависая по таймауту (отсюда остаточные 8.4с - 16.0с LCP). - На некоторых страницах в
srcприлетал абсолютный URL нашего собственного домена (https://pravosleva.pro...). Next.js 11, видя протоколhttps, переключался в режим внешнего проксирования и начинал делать HTTP-запрос сам к себе наружу, упираясь в сетевой блок файрвола (Loopback Failure).
Решение
- Ограничение сетки брейкпоинтов (
deviceSizes) вnext.config.js: Мы жестко вырезали разрешения2048и3840из конфигурации Webpack 5, ограничив потолок Full HD размером в1920px. Нагрузка на ОЗУ сервера упала в 4 раза! - Стриппер доменов (Domain Stripper): Написали санитайзер строки, который на лету выжигает из путей имена собственных доменов, принудительно переводя запросы в локальное чтение файлов с диска:
if (originalSrc.includes('pravosleva.pro')) { originalSrc = originalSrc.replace('https://pravosleva.pro', ''); } - Webpack 5 Изоляция: Чтобы сам бинарный серверный пакет
sharpслучайно не улетел в бандл браузера (что вызывало краш ReactMinified error #130), мы жестко изолировали его на уровне Webpack 5 черезIgnorePluginиconfig.resolve.fallback:if (!isServer) { config.resolve.fallback = { fs: false, path: false }; config.plugins.push(new webpack.IgnorePlugin({ resourceRegExp: /^sharp\$/ })); }
Этап 5. Тонкая калибровка Nginx: Ликвидация кэш-шторма и дедлоков статики
Проблема
В ходе аудита инфраструктуры обнаружилось, что старая конфигурация Nginx содержала ломающее правило try_files для регулярного выражения ~ ^/_next/static/(.*)$, которое принудительно перехватывало запросы к динамическому оптимизатору _next/image и пыталось ложно найти эти эндпоинты в виде физических файлов на диске. Это приводило к отдаче пустых ответов и блокировке картинок. Ситуация усугублялась жесткими заголовками add_header Cache-Control 'no-store, no-cache', которые полностью уничтожали кэширование: при каждом переходе по страницам браузер заново скачивал мегабайты JS-чанков Webpack 5 из сети, перегружая сетевой стек и удерживая LCP на высоких значениях.
Решение
Мы полностью вырезали деструктивный try_files и развернули два изолированных, безопасных локейшена. Теперь Nginx берет на себя монопольную скоростную раздачу неизменяемых JS/CSS бандлов с флагом immutable (срок кэша — 1 год), вообще не вмешиваясь в работу динамического медиа-роутера Next.js, а для папки статики public включен мягкий режим валидации кэша:
# Кэшируем СТРОГО хэшированные ассеты сборки Webpack 5, разгружая Node.js
location ~ ^/_next/static/(.*)\$ {
alias /root/projects/pravosleva-blog/frontend.nextjs/.next/static/\$1;
expires 365d;
access_log off;
add_header Cache-Control "public, max-age=31536000, immutable";
}
# Безопасный долгосрочный кэш для папки public (картинки, иконки, шрифты)
location /static/ {
alias /root/projects/pravosleva-blog/frontend.nextjs/public/static/;
expires 30d;
access_log off;
add_header Cache-Control "public, max-age=2592000, must-revalidate";
}Результат: Сетевой шторм полностью прекратился. Повторные переходы по блогу SPA-роутером теперь происходят мгновенно за 0 мс, а метрика Speed Index рухнула с 8.1 до рекордных 5.4 секунд, зафиксировав идеальную скорость сборки интерфейса в глазах пользователя!
Этап 6. Динамическая изоляция дев-инструментов: Срезаем TBT до 150 мс по Query-флагу
Проблема
Для удобства отладки на мобильных устройствах в рантайм была интегрирована консоль Eruda. Первоначально мы перевели её обертку на ленивую загрузку через requestIdleCallback. Однако замеры PageSpeed Insights показали аномальный скачок LCP до 20.6 сек и Speed Index до 11.8 сек. Физика этого сбоя оказалась сетевой: краулеры Lighthouse имитируют медленное мобильное 3G-соединение из удаленных дата-центров, и принудительное скачивание тяжелого JS-ядра eruda.min.js при каждом аудите намертво забивало сетевой канал, искажая реальные метрики производительности блога. Вынести инструмент в Web Worker невозможно, так как консоль шпионит за window.console и мутирует DOM, поэтому нам требовалось решение, полностью исключающее Eruda из рантайма для обычных сессий.
Решение
Мы применили паттерн условной инжекции по требованию (On-Demand Injection). Из JSX-разметки и автоматических клиентских скриптов была полностью вычищена любая логика дев-консоли. Вместо этого в хук useEffect корневого файла _app.tsx мы внедрили снайперский валидатор параметров URL. Теперь скрипт отладки физически не существует в рантайме для роботов Lighthouse и стандартных читателей (0 байт трафика и 0 мс нагрузки), но мгновенно разворачивается на клиенте, если в адресную строку вручную дописать секретный query-параметр ?eruda_debug=1:
// Внутри useEffect в корневом файле pages/_app.tsx:
const urlParams = new URLSearchParams(window.location.search);
const isDebugMode = urlParams.get('eruda_debug') === '1';
if (isDebugMode) {
const erudaWrapper = window.document.createElement('script');
erudaWrapper.src = '/static/common/eruda.custom.js';
erudaWrapper.async = true;
window.document.body.appendChild(erudaWrapper);
}Результат: Абсолютная изоляция среды разработки от продакшена. Роботы Lighthouse больше не спотыкаются о дев-пакеты, благодаря чему метрика Total Blocking Time (TBT) зафиксировалась на рекордно минимальных 150 мс 🟢, Speed Index вернулся к эталонным 5.5 секундам 🟢, а LCP упал до 6.6 секунд 🟡, открыв пользователям блога чистый SPA-рантайм максимальной скорости.
Итог
Пройдя через этот каскад оптимизаций, мы доказали: Next.js 11 на базе Webpack 5 способен выдавать вполне приличную скорость загрузки.
Наш финальный результат: FCP — 2.0с, TBT — 440мс, CLS — 0, а LCP упал до великолепных 5.6 секунд, зафиксировав приемлемый результат реактивной архитектуры!
Оптимизируйте бандлы, выносите сторонний JS в воркеры и следите за тем, что ваш сервер делает на этапе SSR. Чистого вам кода и сотых баллов в Lighthouse!

В следующий раз посмотрим на более свежие версии Next. Продолжение следует...
