Если вы хотите, чтобы сайт открывался «из памяти браузера» и не тратил лишние запросы, вам нужны корректные заголовки для статики и дисциплина при выкладке. В этой статье разберём, как согласовать cache-control, etag, immutable, expires, когда и на каких типах файлов их использовать, а также как внедрить грамотное versioning статических ассетов. Покажу рабочие примеры для nginx и apache, включая сценарии для virtual hosting и типичные подводные камни.
Что именно кэшировать и как это работает
Под «статикой» чаще всего понимают CSS/JS, изображения, шрифты, иконки, карты источников, иногда JSON со справочниками. У статики есть два режима доставки: «свежая копия» (revalidation по ETag/Last-Modified) и «долгая жизнь» (годовой TTL) с immutable и versioning в имени файла. Второй вариант быстрее, потому что браузер не делает условные запросы вовсе.
Простое правило: или короткий TTL и валидация через
etag/last-modified, или очень длинный TTL сimmutableи строгим versioning в имени файла. Смешивать подходы точечно можно, но осознанно.
Cache-Control и Expires: что важнее
Cache-Control — современный и приоритетный заголовок управления кэшем. Директивы, которые чаще всего нужны:
public— можно кэшировать в браузерах и промежуточных кэшах;private— только браузер пользователя;max-age=N— секунды жизни объекта;no-cache— кэшировать можно, но перед использованием нужно валидировать (условный запрос);no-store— запрещает хранение вообще (для чувствительных ответов, HTML админок);immutable— браузер не будет пытаться валидировать объект в течениеmax-age, если URL неизменен;s-maxage— какmax-age, но для прокси/CDN;stale-while-revalidateиstale-if-error— политика «можно отдавать устаревшее» во время обновления/ошибок (пригодно за прокси).
Expires — устаревший, но ещё полезный заголовок с абсолютной датой. Если присутствуют оба, браузер ориентируется на Cache-Control. Имеет смысл выставлять Expires в дополнение к Cache-Control ради старых клиентов и некоторых прокси.
ETag и Last-Modified: валидаторы и 304
ETag — «отпечаток» версии ресурса. При повторном запросе браузер отправляет If-None-Match; если совпало — сервер отвечает 304 Not Modified без тела. Last-Modified работает аналогично через If-Modified-Since, но сравнивает дату.
Где они полезны:
- Для ресурсов без жёсткого versioning (например, аватарки пользователей, админские CSS/JS), когда правки редки и невелики.
- Для HTML и JSON, если вы не хотите долгих TTL и предпочитаете свежесть с минимальным трафиком.
Где лучше не полагаться на ETag:
- Файлы с хешем в имени и
immutable— валидатор не нужен и только создаёт лишние заголовки; - Кластеры, где генерация
ETagзависит от inode/mtime — разные сервера могут выдавать разные значения, и кэш не попадёт. В таких случаях настройте составETagили отключите его на статике.

Immutable и versioning: идеальная пара для статики
immutable говорит браузеру «если URL не менялся, считать ресурс неизменяемым до истечения max-age». Чтобы это было безопасно, используйте versioning в имени файла: app.abc123.css, vendor.9f8e1.js, logo.20250110.svg. Хеш как часть имени — золотой стандарт.
Почему лучше не использовать query string (?v=123) для статической версии:
- Некоторые прокси/кэши могут агрессивно игнорировать параметры либо иметь отдельные политики;
- Файловые CDN/облачные статики проще инвалидацировать по пути, чем по параметрам;
- Отладка и дедупликация артефактов в сборках проще, когда версия в имени файла.
Практика выкладки: HTML ссылается на актуальные *.hash.* ассеты. Старые хеши не удаляйте сразу — оставьте на 7–30 дней, чтобы посетители с «долгими» кэшами не получали 404.
Стратегии по типам контента
HTML
Страницы лучше отдавать с Cache-Control: no-cache или коротким max-age (например, 60–300 секунд) плюс ETag/Last-Modified. Так вы сохраните свежесть и получите 304 при повторных заходах.
CSS/JS/шрифты/изображения
Для версионированных файлов выбираем долгий TTL (год) и immutable. Для не-версионированных — умеренный TTL (дни/недели) плюс валидаторы.
API JSON и админка
Для чувствительных данных — no-store, чтобы ничего не попадало в кэш, особенно на общих компьютерах. Для публичных справочников можно настроить no-cache с валидаторами.
Nginx: два рабочих варианта
Вариант А: «современный» только с Cache-Control
# Включить/отключить валидаторы при необходимости
etag on;
last_modified on;
# Долгий TTL и immutable для версионированных ассетов (имя с хешем)
location ~* \.(?:css|js|mjs|png|jpe?g|gif|svg|webp|ico|woff2?|ttf)$ {
# Если у вас строгий versioning в имени файла — можно отключить ETag
etag off;
add_header Cache-Control "public, max-age=31536000, immutable" always;
}
# Умеренный TTL и валидация для не-версионированных
location ~* /assets/(?:legacy|uploads)/ {
etag on;
add_header Cache-Control "public, max-age=86400" always;
}
# HTML без кэша в прокси и с обязательной валидацией в браузере
location ~* \.(?:html)$ {
add_header Cache-Control "no-cache" always;
}
Вариант B: Cache-Control + Expires для совместимости
# Управляем и абсолютной датой Expires
location ~* \.(?:css|js|mjs|png|jpe?g|gif|svg|webp|ico|woff2?|ttf)$ {
etag off;
expires 1y; # добавит Expires и Cache-Control: max-age, но мы переопределим CC ниже
add_header Cache-Control "public, max-age=31536000, immutable" always;
}
location ~* \.(?:html)$ {
expires -1; # Expires в прошлом
add_header Cache-Control "no-cache" always;
}
Замечания:
add_header ... always;гарантирует заголовки даже на 304/4xx/5xx;- Если у вас кластер, следите, чтобы часы синхронизировались (NTP), иначе
Last-Modifiedможет «скакать»; - Не забывайте про
Vary: Accept-Encodingпри раздаче gzip/br; в современных сборках Nginx это добавляется автоматически, но проверьте.
Если нужен полный контроль над конфигами, модулями и пакетами — разворачивайте всё на VDS. Для типовых сайтов с панелью подойдут тарифы на виртуальный хостинг.
Дополнительно про защитные заголовки (CSP, HSTS и др.) смотрите разбор в статье HTTP security headers для Nginx/Apache.
Apache (.htaccess): для shared/virtual hosting
На virtual hosting чаще всего доступен только .htaccess. Включаем mod_expires и mod_headers:
<IfModule mod_expires.c>
ExpiresActive On
# По умолчанию ничего не кэшируем надолго
ExpiresDefault "access plus 0 seconds"
# Долгий TTL для типичных статических типов
ExpiresByType text/css "access plus 1 year"
ExpiresByType application/javascript "access plus 1 year"
ExpiresByType application/x-javascript "access plus 1 year"
ExpiresByType image/jpeg "access plus 1 year"
ExpiresByType image/png "access plus 1 year"
ExpiresByType image/gif "access plus 1 year"
ExpiresByType image/webp "access plus 1 year"
ExpiresByType image/svg+xml "access plus 1 year"
ExpiresByType image/x-icon "access plus 1 year"
ExpiresByType font/woff2 "access plus 1 year"
ExpiresByType font/woff "access plus 1 year"
</IfModule>
<IfModule mod_headers.c>
# Долгий TTL + immutable для статики
Header set Cache-Control "public, max-age=31536000, immutable" "expr=%{CONTENT_TYPE} =~ m#^(text/css|application/(javascript|x-javascript)|image/|font/)#"
# HTML: без кэша, только валидировать
<FilesMatch "\.(?:html)$">
Header set Cache-Control "no-cache"
</FilesMatch>
</IfModule>
# ETag: избегаем inode для согласованности между серверами
FileETag MTime Size
# Можно убрать ETag у версионированных файлов (если имена с хешем)
<FilesMatch "\.(?:css|js|mjs|png|jpe?g|gif|svg|webp|ico|woff2?|ttf)$">
Header unset ETag
</FilesMatch>
Если у вас старая версия Apache без expr в Header, используйте FilesMatch для назначения Cache-Control по расширениям.

На виртуальном хостинге и в панелях часто есть предустановленные политики. Проверяйте фактические ответы через curl -I и DevTools, чтобы убедиться, что панель не переписывает ваши заголовки.
Как распознать «версионированный» файл в конфиге
Иногда нужно различать файлы с хешем в имени и обычные. Пример для Nginx:
location ~* ^/assets/.+\.[0-9a-f]{8,}\.(?:css|js|mjs|png|jpe?g|gif|svg|webp|ico|woff2?|ttf)$ {
etag off;
add_header Cache-Control "public, max-age=31536000, immutable" always;
}
location ~* ^/assets/.+\.(?:css|js|mjs|png|jpe?g|gif|svg|webp|ico|woff2?|ttf)$ {
etag on;
add_header Cache-Control "public, max-age=604800" always; # 7 дней
}
Аналогично в Apache можно повесить разные блоки FilesMatch на разные регулярные выражения.
Проверка и отладка
Минимальный чек-лист:
curl -I https://site.tld/assets/app.abc123.css— убедитесь, что приходят нужныеCache-Control/Expiresи валидаторы;- В DevTools браузера проверьте колонку «Size» — должен быть «(from disk cache)»/«(from memory cache)» на втором заходе;
- Измените версию ассета и убедитесь, что новая ссылка реально подхватывается без перезагрузки кэша;
- Проверьте
Varyдля сжатых ресурсов и наличиеContent-Encoding; - На CDN проконтролируйте, что политики кэша не конфликтуют с вашими заголовками (например, не затирают
immutable).
Типовые ошибки и как их исправить
- Долгий TTL на невариантном HTML. Итог: пользователи видят старые страницы. Решение:
no-cacheили короткийmax-ageплюс валидаторы. - Меняете файл на месте при
immutable. Браузер не будет проверять обновление. Решение: всегда менять имя файла (versioning). - Несогласованные
ETagна кластере. Разные сервера — разныеETag. Решение: упроститьETag(толькоMTime Size) либо отключить для статики. - Ставите
no-storeна всё. Быстрое, но убивает производительность. Решение: применять точечно к чувствительным данным. - Неправильный
Vary. ОтсутствиеVary: Accept-Encodingломает совместимость с прокси. Решение: включить авто-добавление или явно задать. - Ставите огромный
max-ageбез versioning. Пользователи «залипают» на старых картинках/скриптах. Решение: дисциплина versioning.
Практика деплоя с учётом кэша
Грамотный пайплайн раздачи статики выглядит так:
- Собираете ассеты с контент-хешами в имени;
- Заливаете новые файлы рядом со старыми (старые временно оставляете);
- Обновляете HTML-шаблоны, чтобы они ссылались на новые имена;
- Выставляете заголовки: для новых —
public, max-age=31536000, immutable; - Через 7–30 дней чистите старые ассеты (когда уверены, что активных кэшей не осталось).
Если что-то пошло не так, быстрый «откат» — вернуть старые HTML/шаблоны, указывающие на предыдущие версии файлов. Поэтому важно сохранять несколько прошлых поколений ассетов. Про миграции без простоя читайте в статье Переезд сайта без простоя.
Особенности для shared/virtual hosting
На виртуальном хостинге без root-доступа чаще всего доступен только .htaccess (Apache) и панельные пресеты. Настроить cache-control, expires, etag можно малыми силами, как показано выше. Если на фронте проксирует Nginx, убедитесь, что он не перезаписывает ваши заголовки. В ряде конфигураций панели предоставляют «переписчики» заголовков — проверяйте фактический ответ через curl -I и DevTools.
Короткие «пресеты» для копипаста
Nginx: только versioned статика
location ~* ^/(assets|static)/.+\.[0-9a-f]{8,}\.(?:css|js|mjs|png|jpe?g|gif|svg|webp|ico|woff2?|ttf)$ {
etag off;
add_header Cache-Control "public, max-age=31536000, immutable" always;
}
Apache: аккуратное кэширование HTML
<FilesMatch "\.(?:html)$">
Header set Cache-Control "no-cache"
FileETag MTime Size
</FilesMatch>
Итоги
Для максимальной производительности статики выбирайте стратегию «долгий TTL + immutable + versioning в имени файла». Для HTML и не-версионированных ресурсов опирайтесь на etag/last-modified с короткими TTL. В nginx и apache это настраивается несколькими директивами. Отлаживайте заголовки с помощью curl -I и инструментов разработчика — это быстро окупается сокращением запросов, трафика и ускорением загрузки.


