Оптимизация статического контента в 2026: gzip, brotli, Cache-Control и ETag | AdminWiki

Оптимизация статического контента в 2026: gzip, brotli, Cache-Control и ETag

22 сентября 2026 16 мин. чтения
Содержание статьи

Что даёт оптимизация статики и с чего начать

Отдача статики ускоряется тремя независимыми слоями: сжатие на лету (gzip, brotli), кеширование в браузере и на CDN (Cache-Control, ETag), уменьшение объёма самих файлов (минификация CSS и JS, WebP и AVIF, критический CSS). Слои не подменяют друг друга. Brotli бесполезен, если браузер при каждом переходе заново тянет 300 КБ CSS, а годовой max-age не спасает, когда один баннер весит 2 МБ.

Порядок работ определяет стоимость изменений. Сжатие и кеш настраиваются в конфиге Nginx за 30-60 минут и не требуют пересборки фронтенда. Минификация, версионирование и изображения требуют правок в сборочном пайплайне, поэтому идут вторыми. Эффект первого слоя виден сразу, второй складывается с ним.

Ориентиры по выигрышу: сжатие текстовых ассетов снижает передаваемый вес на 60-80%, корректный Cache-Control убирает повторные загрузки при навигации, минификация добавляет 10-30% поверх сжатия, перевод изображений в WebP уменьшает их вес на 25-35% относительно JPEG. Цифры ориентировочные: на маленьком сайте с одним файлом CSS экономия легко съедается накладными расходами на заголовки. Отдельно держите в голове риск: агрессивный кеш без версионирования ломает деплой, поэтому схему с хешами в именах файлов стоит продумать до того, как выставить max-age=31536000.

Приведённые проценты и тайминги - типовые ориентиры, а не замеры конкретного сайта. Сверяйте их со своим baseline: на разных стеках и профилях трафика разброс большой.

Три слоя оптимизации: сжатие, кеш, вес файлов

СлойЧто убираетМетрикиВремя на настройку
Сжатие (gzip, brotli)Лишние байты в текстовых ответахTransfer Size, Total Byte Weight, TTFB30-60 минут
Кеширование (Cache-Control, ETag)Повторную загрузку тех же файловПовторные визиты, LCP при навигации, нагрузка на сервер30-60 минут
Вес файлов (минификация, изображения, критический CSS)Байты внутри самих файловLCP, Total Byte Weight, CLS при правках разметкиот нескольких часов до дня

Практический вывод из таблицы: начните со сжатия и заголовков, потому что они дают результат без релиза фронтенда, и только потом беритесь за сборку.

Как измерить текущее состояние до изменений

Без baseline вы не отличите эффект сжатия от эффекта кеша. Снимите два профиля загрузки: холодный (в Chrome DevTools включена галочка Disable cache) и тёплый (обычная перезагрузка). Холодный покажет реальный transfer size, тёплый - сколько запросов закрывается из кеша.

  1. Lighthouse в Chrome DevTools в режиме Incognito: разделы Performance и Best Practices, выгрузка отчёта в JSON для последующего сравнения.
  2. WebPageTest: тест из региона, близкого к вашей аудитории, профили Cable и Mobile, просмотр waterfall.
  3. Вкладка Network: колонки Size и Transferred, фильтр по типу ресурса. Разница между Size и Transferred и есть эффект сжатия.

Зафиксируйте четыре числа: TTFB, LCP, Total Byte Weight и количество запросов. Эти же числа снимайте после каждого слоя отдельно, иначе не поймёте, что именно дало прирост.

Сжатие gzip и brotli в Nginx: настройка и подводные камни

Конфиг gzip для Nginx

Блок ниже рассчитан на современный сайт с HTTPS. Директива gzip_vary on добавляет заголовок Vary: Accept-Encoding, без которого CDN или прокси может отдать сжатый ответ клиенту, который его не поддерживает, и тот получит бинарный мусор вместо страницы.

gzip on;
gzip_comp_level 6;
gzip_min_length 1024;
gzip_vary on;
gzip_proxied any;
gzip_static on;
gzip_types
    text/plain
    text/css
    text/xml
    text/javascript
    application/javascript
    application/json
    application/xml
    application/rss+xml
    image/svg+xml;
# gzip_disable "msie6";  # legacy, в 2026 году можно опустить

gzip_comp_level 6 - компромисс: уровни 1-3 дают заметно больший размер, 8-9 почти не экономят байты, но греют CPU. gzip_min_length 1024 отсекает мелкие ответы, где сжатие не окупает служебные заголовки. gzip_proxied any нужен, когда перед Nginx стоит CDN или балансировщик: без него запросы с Via и X-Forwarded-For не сжимаются. gzip_static on заставляет сервер искать заранее собранный файл с расширением .gz и отдавать его без затрат CPU.

Подключение brotli и предсжатие статики

Brotli обычно подключают модулем ngx_brotli: он ставится пакетом из репозитория дистрибутива либо собирается вместе с Nginx. После установки модули объявляются в начале конфигурации.

load_module modules/ngx_http_brotli_filter_module.so;
load_module modules/ngx_http_brotli_static_module.so;

brotli on;
brotli_comp_level 6;
brotli_min_length 1024;
brotli_static on;
brotli_types
    text/plain
    text/css
    application/javascript
    application/json
    application/xml
    image/svg+xml;

На тексте brotli выигрывает у gzip 15-25% при сопоставимом уровне сжатия. Браузеры запрашивают brotli через Accept-Encoding: br только при HTTPS, поэтому включать его имеет смысл на TLS-трафике. Для устаревших клиентов gzip остаётся fallback, и оба блока должны жить в конфиге одновременно.

Главная ловушка brotli - уровень сжатия на лету. brotli_comp_level 11 выжимает максимум байтов, но на каждый запрос тратит десятки миллисекунд CPU; для высоконагруженного сервера ставьте 5-6.

Экономию без нагрузки даёт предсжатие на этапе сборки. Файлы .br и .gz создаются один раз в CI, а brotli_static on отдаёт их как готовые.

brotli -q 11 app.js -o app.js.br
gzip -9 -k app.js
find dist -type f -name "*.css" -exec brotli -q 11 -k {} \;

Уровень 11 на сборке оправдан: он применяется один раз и потом не расходует ресурсы сервера. Не забудьте, что brotli_static работает только при наличии файлов с расширением .br рядом с оригиналом.

Почему нельзя сжимать уже сжатые форматы

JPEG, PNG, WebP, AVIF, MP4, ZIP, GZ и WOFF2 уже используют внутреннее сжатие. Повторный gzip или brotli поверх них даёт 0-2% выигрыша и тратит CPU на каждый запрос. Типичный симптом ошибки: изображения отдаются медленно, а load average на Nginx растёт при просмотре галереи.

Лечится явным списком MIME-типов. Держите в gzip_types и brotli_types только текстовые форматы и SVG. Проверка занимает секунды: ответ на запрос картинки не должен содержать заголовок Content-Encoding.

curl -I https://example.com/photo.jpg

Если в ответе видите Content-Encoding: br или gzip, тип попал в список по ошибке. Разбор типовых причин, почему сжатие не срабатывает, и способы проверки через онлайн-сервисы и DevTools описаны в материале про проверку и анализ gzip-сжатия сайта.

Заголовки Cache-Control и ETag: как настроить кеширование статики

Статика делится на две категории, и для каждой нужен свой набор заголовков. Файлы с хешем в имени можно кешировать на год: изменение содержимого меняет имя, поэтому старая копия в кеше пользователя безвредна. Всё остальное, включая HTML, обязано перепроверяться при каждом запросе.

Матрица заголовков: что ставить для каких файлов

ФайлCache-ControlОбоснование
app.abc123.js, style.def456.csspublic, max-age=31536000, immutableИмя меняется вместе с содержимым, ревалидация не нужна
index.htmlno-cacheБраузер обязан спросить сервер, но может взять копию при 304
favicon.icopublic, max-age=86400Меняется редко, сутки безопасны
robots.txtpublic, max-age=3600Правки должны подхватываться в течение часа
Ответы API с персональными даннымиprivate, no-storeЗапрет на запись в кеш браузера и промежуточных узлов

Разница между no-cache и no-store ломает конфиги чаще всего. no-cache разрешает хранить копию, но требует ревалидации перед каждым использованием, и именно он подходит для HTML. no-store запрещает запись целиком, что для разметки страницы даёт лишние полные загрузки.

Для CDN добавьте s-maxage: он переопределяет max-age для промежуточных кешей и позволяет держать на периферии одну политику, а в браузере другую. Параметр stale-while-revalidate=60 разрешает отдать устаревшую копию, пока фоном идёт обновление, что сглаживает всплески после инвалидации.

location ~* \.(js|css|woff2|webp|avif|svg)$ {
    add_header Cache-Control "public, max-age=31536000, immutable";
    access_log off;
}

location = /index.html {
    add_header Cache-Control "no-cache";
}

Не дублируйте политику директивами expires и add_header Cache-Control одновременно: Nginx отправит два заголовка Cache-Control, и поведение клиента станет непредсказуемым. Готовые блоки location для CSS, JS, изображений и шрифтов с CORS и проверкой через curl собраны в статье про кеширование статических файлов в Nginx.

ETag и Last-Modified: как работает 304

Механика ревалидации проста: браузер хранит ETag файла и при следующем запросе отправляет его в заголовке If-None-Match. Если содержимое не изменилось, сервер отвечает 304 Not Modified без тела, и экономится весь трафик файла. При ответе с immutable браузер вообще не выходит на сервер до истечения года.

Проблема начинается на кластере. Nginx по умолчанию строит ETag из времени модификации (mtime) и размера файла. Если вы раскатываете релиз на три сервера в разное время, mtime отличается, ETag отличается, и балансировщик возвращает пользователю то 304, то 200 с полным телом. Один и тот же файл может инвалидироваться на каждом запросе.

Варианты решения: выключить etag off и опираться на Last-Modified, что дешевле по CPU, но даёт гранулярность в одну секунду; собирать артефакты с одинаковым mtime (например, через touch -d в пайплайне); отдавать статику через CDN с единым origin, чтобы ревалидацию выполнял только один узел. Если в конфиге есть ETag, проверьте одну и ту же ссылку на разных серверах через curl -I и сравните значение заголовка.

Ошибка: агрессивный кеш без инвалидации

Самая дорогая ошибка в этой теме: заголовок public, max-age=31536000 на файле без хеша в имени. После деплоя часть пользователей продолжает работать со старым JS и получает ошибки в консоли, а сервер не видит ни одного запроса и не может ничего исправить. Разбор этой ситуации с точки зрения механики кеша и способов отката приведён в разделе о типовых ошибках ниже.

Версионирование файлов для безопасной инвалидации кеша

Схема, при которой годовой max-age становится безопасным, строится на хеше содержимого в имени файла. Сборщик читает содержимое ассета, считает хеш и формирует имя вида app.7f3c91.js. Разметка страницы ссылается на новое имя, а файл со старым именем остаётся на диске, чтобы пользователь с устаревшим HTML не получил 404.

{
  "app.js": "app.7f3c91.js",
  "style.css": "style.4a0be2.css"
}

Манифест сборки связывает логическое имя с хешированным и служит точкой интеграции для шаблонизатора или серверного рендеринга. Nginx в такой схеме отдаёт статику директивой try_files, а сами имена остаются неизменными до следующего релиза.

Content hash vs query string

Версионирование через app.js?v=123 проще: имя файла не меняется, правится только строка запроса. Практика показывает два минуса. Часть CDN и прокси по умолчанию кеширует по пути без query, поэтому новая версия может не доехать до пользователя. Плюс некоторые промежуточные кеши хранят только последнюю копию, и переход между версиями ломается непредсказуемо.

Content hash лишён этих проблем: имя уникально, любой кеш трактует разные имена как разные ресурсы, инвалидация не требуется вовсе. Цена - пересборка HTML при каждом релизе и необходимость хранить старые файлы. Ещё один плюс в пользу хеша: при отдаче через CDN не нужно оформлять отдельный запрос на инвалидацию, потому что путь уже новый.

Порядок деплоя без простоя

  1. Собрать ассеты с новыми хешами и не удалять предыдущее поколение файлов.
  2. Загрузить новые файлы на сервер или в объектное хранилище за CDN.
  3. Атомарно обновить HTML: заменить каталог через symlink или залить новую версию разметки целиком.
  4. Выдержать окно 7-30 дней, в течение которого у пользователей ещё жив старый HTML.
  5. Удалить старые ассеты и почистить кеш CDN по маске.

Такой порядок исключает окно, когда разметка ссылается на файл, которого ещё нет на диске. Атомарная подмена каталога через symlink надёжнее, чем rsync поверх работающей директории: rsync с флагом --delay-updates уменьшает окно неконсистентности, но не убирает его полностью. Полезно помнить, что open_file_cache на Nginx держит дескрипторы файлов, поэтому после массовой инвалидации стоит прогреть кеш повторными запросами; практические приёмы разобраны в статье про очистку кеша статики и изображений в Nginx.

Минификация CSS и JS и критический CSS

Команды минификации

Минификация убирает пробелы, комментарии и сокращает имена переменных, но не меняет логику файла. Экономия на тексте составляет 10-30%, и она складывается со сжатием: сначала уменьшаете файл, потом gzip сжимает остаток.

terser dist/app.js -c -m -o dist/app.min.js
postcss src/style.css --use cssnano -o dist/style.min.css
ls -lh dist/app.min.js dist/app.js

terser с флагом -c включает сжатие выражений, -m переименовывает локальные переменные. cssnano подключается плагином PostCSS. В 2026 году Vite, Webpack и Rollup делают это автоматически в production-режиме, и ручные команды нужны только для legacy-пайплайнов или разовых правок. Всегда сравнивайте размер до и после: если минифицированный файл вырос, значит сборщик добавил полифилы или source map в тот же файл.

Критический CSS без render-blocking

Подключённый в head CSS блокирует отрисовку: браузер ждёт загрузки стилей, прежде чем показать первый пиксель. Приём с критическим CSS делит стили на две части. Стили above-the-fold (то, что видно без прокрутки) встраиваются инлайном в head, остальной CSS грузится асинхронно.

<link rel="preload" href="/style.min.css" as="style" onload="this.rel='stylesheet'">
<noscript><link rel="stylesheet" href="/style.min.css"></noscript>

Извлечь критическую часть помогают инструменты critical или penthouse: они открывают страницу в headless-браузере и вытаскивают правила, применимые к первому экрану. Инлайн критического CSS увеличивает HTML, поэтому держите его в пределах 10-14 КБ, иначе рост разметки перекроет выигрыш. Обязательно проверьте результат на нескольких разрешениях: неполный критический CSS даёт FOUC, когда стили доезжают уже после первой отрисовки. Для одностраничных приложений с одним корневым контейнером приём слабее, чем для многостраничных сайтов.

Оптимизация изображений: WebP и AVIF

Конвертация и сравнение размеров

Изображения обычно занимают большую часть веса страницы. Порядок величин на типовой фотографии 1600x900 при сопоставимом визуальном качестве: JPEG 200 КБ, WebP 140 КБ, AVIF 100 КБ. WebP даёт 25-35% экономии относительно JPEG, AVIF отыгрывает ещё 20-30% у WebP, но кодируется заметно дольше и требует более свежих клиентов.

cwebp -q 80 image.jpg -o image.webp
avifenc -q 60 image.png image.avif
ls -lh image.jpg image.webp image.avif

На простой графике, скриншотах интерфейса и логотипах разрыв между форматами меньше, а иногда SVG оказывается легче любого растрового варианта. AVIF оправдан на фотографиях и в каталогах с большим количеством картинок, где кодирование выполняется один раз на этапе сборки. Повторная конвертация уже сжатого изображения без визуальной проверки даёт артефакты: сохраняйте оригиналы и пересобирайте производные из них.

Разметка picture с fallback

Браузер выбирает первый поддерживаемый источник, поэтому порядок элементов определяет результат. AVIF идёт первым, WebP вторым, JPEG остаётся в img как страховка.

<picture>
  <source type="image/avif" srcset="hero.avif 1x, hero@2x.avif 2x">
  <source type="image/webp" srcset="hero.webp 1x, hero@2x.webp 2x">
  <img src="hero.jpg" width="1200" height="630" loading="lazy" alt="Схема">
</picture>

Два атрибута дают почти бесплатный выигрыш. loading="lazy" откладывает загрузку изображений ниже первого экрана, а width и height резервируют место и убирают скачки разметки, из-за которых растёт CLS. Для адаптивных версий добавьте srcset и sizes, чтобы мобильный клиент не тянул десктопную картинку. Форматы WebP и AVIF не нужно включать в gzip_types или brotli_types: они уже сжаты, и повторная компрессия только расходует CPU.

Как измерить результат: Lighthouse и WebPageTest

Чек-лист замера до и после

Замеряйте каждый слой отдельно, иначе цифры смешаются. Список метрик, на которые смотреть: TTFB, LCP, CLS, INP, Total Byte Weight, количество запросов и суммарный transferred. Порядок такой: baseline, затем после сжатия, затем после заголовков кеша, затем после минификации и изображений. Отчёты Lighthouse сохраняйте в JSON, иначе сравнить две недели спустя будет нечем.

В Lighthouse запускайте аудит в Incognito без расширений, в WebPageTest выбирайте регион, близкий к аудитории, и профили Cable и Mobile. Waterfall наглядно показывает, какие запросы идут до LCP и сколько времени тратится на ожидание сервера вместо передачи байтов.

Проверка сжатия и кеша через curl

Быстрая проверка конфигурации не требует браузера. Запросите ассет с явным Accept-Encoding и посмотрите на Content-Encoding, затем проверьте политику кеша.

curl -H 'Accept-Encoding: br,gzip' -I https://example.com/app.7f3c91.js
curl -I https://example.com/app.7f3c91.js
curl -I -H 'If-None-Match: "abc123"' https://example.com/app.7f3c91.js

Первый запрос должен вернуть Content-Encoding: br или gzip, второй - Cache-Control и ETag, третий при совпадающем идентификаторе - код 304 без тела. Если Content-Encoding отсутствует, проверьте, входит ли MIME-тип файла в gzip_types или brotli_types и превышает ли файл gzip_min_length. Пустой заголовок Cache-Control означает, что политика не попала в нужный location. Дополнительно стоит убедиться, что CDN не перезаписывает ваши заголовки своим правилом.

Типичные ошибки и как их исправить

Ниже набор ошибок, которые встречаются в продакшене чаще всего, в формате симптом - причина - исправление.

Ошибка: агрессивный кеш без инвалидации

Симптом: после релиза часть пользователей видит старую версию интерфейса и получает ошибки в консоли. Причина: public, max-age=31536000 стоит на файле без хеша в имени. Исправление: перейти на content hash в именах ассетов, а HTML отдавать с no-cache. Если релиз уже уехал, экстренный откат делается сменой имени файла или правкой ссылки в разметке; версионирование через query-параметр работает хуже, потому что часть CDN игнорирует строку запроса при кешировании.

Ошибка: сжатие уже сжатых форматов

Симптом: рост CPU на Nginx при отдаче изображений и видео, заметный на графиках мониторинга. Причина: image/jpeg, image/png, image/webp, image/avif, video/mp4, application/zip и font/woff2 попали в gzip_types или brotli_types. Исправление: оставить в списках только текстовые типы и SVG. Проверка: ответ на запрос .jpg не должен содержать Content-Encoding.

Ошибка: ETag ломается на нескольких серверах

Симптом: за балансировщиком кеш работает нестабильно, браузер то получает 304, то снова качает файл целиком. Причина: Nginx строит ETag из mtime и размера, а на серверах A и B время модификации разное. Исправление: etag off и опора на Last-Modified, либо одинаковый mtime у артефактов, либо единый origin за CDN.

Ошибка: двойное сжатие на CDN и origin

Симптом: заголовки в ответе противоречат друг другу, сжатие применяется дважды или не применяется вовсе. Причина: и CDN, и Nginx настроены сжимать один и тот же контент. Исправление: выберите одну точку сжатия. Если сжимает CDN, отключите gzip и brotli на origin либо настройте CDN передавать Accept-Encoding как есть.

Ошибка: brotli_comp_level 11 на лету

Симптом: пики CPU и рост времени ответа при высокой нагрузке. Причина: максимальный уровень сжатия применяется к каждому запросу. Исправление: brotli_comp_level 5-6 для сжатия на лету, а уровень 11 использовать только при предсжатии в сборке.

Ошибка: удаление старых ассетов сразу после деплоя

Симптом: пользователи со старой разметкой получают 404 на JS и видят неработающий интерфейс. Причина: файлы предыдущего релиза удалены в момент выката новой версии. Исправление: держать старое поколение 7-30 дней и удалять его отдельной задачей в CI после истечения окна.

Что изменилось в 2026 году и что проверить в своём стеке

Часть советов прошлых лет устарела. С распространением HTTP/2 и HTTP/3 объединение всех файлов в один bundle потеряло смысл: мультиплексирование снимает штраф за количество запросов, а один большой файл мешает кешировать по частям. Разбивайте код по маршрутам и обновляйте только изменившиеся чанки.

Brotli остаётся основным алгоритмом для текста, gzip - fallback для старых клиентов. Zstandard (zstd) получает поддержку в браузерах как Content-Encoding и обсуждается в контексте HTTP/3, но пока не вытеснил brotli из веба; стоит проверить, поддерживает ли ваш CDN zstd на стороне периферии. Core Web Vitals в 2026 году измеряются по LCP, INP и CLS, где INP заменил FID в качестве метрики отзывчивости.

Что проверить в своём стеке: версию Nginx и наличие модуля ngx_brotli, поддержку AVIF на стороне CDN, актуальные пороги Lighthouse для вашей категории сайта и то, не перезаписывает ли CDN заголовки кеша. Для серверного слоя кеша полезно отдельно посмотреть настройку proxy_cache и мониторинг хитрейта в руководстве про полную настройку кеширования в Nginx.

Финальный чек-лист оптимизации

  • gzip включён, gzip_vary on, уровни 5-6, конфиг проверен через curl.
  • brotli включён на HTTPS, brotli_comp_level 5-6, brotli_static on при наличии файлов .br.
  • JPEG, PNG, WebP, AVIF, MP4, ZIP и WOFF2 исключены из списков сжатия.
  • Хешированные ассеты отдаются с public, max-age=31536000, immutable.
  • HTML и файлы без хеша отдаются с no-cache, а не с no-store.
  • ETag не ломается при отдаче с нескольких серверов или отключён в пользу Last-Modified.
  • В именах файлов используется content hash, а не query-параметр.
  • Старые ассеты живут 7-30 дней после релиза и удаляются отдельной задачей.
  • CSS и JS минифицированы, размер до и после сверен.
  • Критический CSS встроен в head и укладывается в 10-14 КБ.
  • Изображения отдаются в WebP или AVIF с fallback на JPEG, заданы width, height и loading="lazy".
  • Сохранены отчёты Lighthouse и WebPageTest до и после изменений, зафиксированы TTFB, LCP, CLS, INP и Total Byte Weight.

Начните с двух действий, которые дают эффект сегодня: добавьте в конфиг Nginx блок сжатия и проверьте один JS-файл через curl, затем выставите Cache-Control по матрице выше. После этого переходите к версионированию и сборке, чтобы агрессивный кеш стал безопасным.

Поделиться:
Сохранить гайд? В закладки браузера