Если упростить, то «идеальный» фронтенд в продакшене складывается из двух вещей: неизменяемых статических ресурсов с очень долгим TTL и быстрой, предсказуемой доставки через CDN. На практике это означает связку из cache-busting (переменовываем файлы при каждом изменении) и заголовка immutable (говорим браузеру и прокси: «этот URL никогда не меняется»). Плюс корректные Cache-Control и прочие headers на уровне Nginx. Ниже — подробный, но прагматичный гид для админов и девопсов, как всё это собрать без ловушек.
Это легко запускается на origin‑сервере под Nginx: удобно держать его на VDS, а для HTTP/2/3 и CDN сразу оформляйте надёжные SSL-сертификаты.
Зачем immutable и cache-busting
Цель — минимальная латентность и стабильность кэшей. Когда статический файл (CSS, JS, шрифты, изображения) отдаётся с очень длинным max-age или s-maxage, CDN и браузер могут хранить его вечно (или год), не обращаясь к источнику. Но это безопасно только в одном случае: если URL ресурса уникален для конкретного содержимого. Тогда при любом изменении контента вы меняете и URL — это и есть cache-busting.
Директива immutable усиливает гарантию: она подсказывает браузеру, что ресурс по этому адресу не изменится совсем, поэтому нельзя тратить трафик на «пере‑проверки» при перезагрузке страницы или навигации назад/вперёд.
Ключевая идея: уникальный URL для каждого билда, плюс «долгий»
Cache-Controlиimmutable. Тогда кэширование становится детерминированным, а деплой — безопасным и без принудительных очисток CDN.
Модель кэшей: где что хранится
В цепочке запроса обычно участвуют минимум три слоя: браузер пользователя (private cache), CDN/промежуточные прокси (shared cache) и origin (ваш Nginx). Управляем ими через заголовки:
Cache-Control: директивыpublic/private,max-age,s-maxage,immutable,no-cache,no-store,must-revalidate,stale-while-revalidate,stale-if-error.ETagиLast-Modified— основа условных запросов (If-None-Match,If-Modified-Since), когда ресурс нельзя сделать «вечным».Vary— различение кэша по заголовкам (например,Accept-Encoding,Origin,Accept).
Идеал для неизменяемых ресурсов — не полагаться на ETag/Last-Modified, а давать «глубокий» TTL и immutable. Для изменяемых — короткий TTL и валидаторы.

Что такое immutable на практике
Директива immutable в Cache-Control говорит браузеру: «не проверяй обновления при явной перезагрузке, не делай conditional requests для этого URL». Это критично ускоряет повторные заходы и офлайн‑навигацию. Но применять её можно только если URL никогда не «переиспользуется» для нового контента.
Поэтому правило №1: используйте cache-busting. Основные подходы:
- Хеш в имени файла:
app.9f12ab.css,vendor.3c9e.js. Самый надёжный вариант: и браузеры, и CDN воспринимают URL как новый путь. - Версионирование директорий:
/assets/v123/app.css. Тоже надёжно, меняется путь. - Query‑параметр:
/app.css?v=123. Работает, но зависит от настройки CDN (должен учитывать всю строку запроса в ключе кэша). Если CDN игнорирует query‑string, то это опасно.
Отсюда правило №2: если используете query‑версии, убедитесь, что ваш CDN не настроен на «ignore query string». Иначе вы рискуете отдавать старый контент под новым параметром.
Политики кэширования по типам контента
Рассмотрим набор разумных политик:
- HTML (страницы, SPA
index.html):Cache-Control: no-cache(илиmax-age=0, must-revalidate). Браузер может хранить копию, но всегда валидирует. Для CDN можно задатьs-maxageнебольшим (например,60) плюсstale-while-revalidate/stale-if-error, чтобы сгладить пики. - API JSON: короткий
max-ageилиno-cache, обязательноETag. CDN — по нагрузке: иногдаs-maxage=30..300иstale-while-revalidate, если ответы детерминированы. - Статика с cache-busting (CSS/JS/шрифты/изображения/видео/иконки):
Cache-Control: public, max-age=31536000, immutable. Для CDN можно добавитьs-maxage(если он у вас отличается отmax-age), но часто достаточно одного. - Без cache-busting (например, медиа, которые могут перезаписываться тем же именем): ставьте небольшой
max-age(например,300..3600) и включайте валидаторы.
Nginx: минимально удобная конфигурация
Ниже пример, где мы автоматически отличаем «захешированные» имена файлов и даём им «вечный» TTL с immutable. Для остальной статики — умеренный TTL, для HTML — валидация.
# В http{}
map $uri $is_hashed {
default 0;
~-[0-9a-f]{8,}\.(?:css|js|mjs|map|png|jpg|jpeg|webp|avif|gif|svg|ico|woff2?|ttf|eot)$ 1;
}
map $is_hashed $cc_static {
1 "public, max-age=31536000, immutable";
0 "public, max-age=300";
}
server {
listen 80;
server_name example.org;
# HTML: всегда валидируем (браузер), CDN можно настроить отдельно
location ~* \.html$ {
add_header Cache-Control "no-cache" always;
try_files $uri =404;
}
# Статика: hashed получает вечный TTL, остальное — умеренный
location ~* \.(?:css|js|mjs|map|png|jpg|jpeg|webp|avif|gif|svg|ico|woff2?|ttf|eot)$ {
add_header Cache-Control $cc_static always;
# по желанию: add_header Vary "Accept-Encoding" always;
try_files $uri =404;
}
# По умолчанию
location / {
add_header Cache-Control "no-cache" always;
try_files $uri $uri/ =404;
}
}
mapраспознаёт паттерн «тире + 8+ hex в имени + ожидаемое расширение». Настройте список расширений под себя.immutableвыдаём только «гарантированно неизменным» URL.- Для HTML используется
no-cache: копия может храниться, но при запросе происходит валидация.
Добавим CDN‑настройки и устаревание
Если у вас есть CDN и вы хотите более агрессивно использовать общий кэш, можно разделить политику для браузера и для CDN через s-maxage, а также включить «устаревание» на просадках источника:
map $is_hashed $cc_static {
1 "public, max-age=31536000, s-maxage=31536000, immutable, stale-while-revalidate=86400, stale-if-error=86400";
0 "public, max-age=300, s-maxage=600, stale-while-revalidate=600, stale-if-error=600";
}
Не все CDN учитывают stale-while-revalidate/stale-if-error одинаково, но даже если они игнорируются на периметре, браузерам эти директивы полезны.
Где пригодятся ETag и Last-Modified
На «вечной» статике с immutable валидаторы излишни — запросы валидации не происходят. Но для ресурсов без cache-busting они нужны. Пара нюансов:
- В Nginx
etagдля статических файлов обычно включён, однако при on‑the‑fly компрессии (gzip) слабые/конфликтующие теги могут отключаться. Проверьте фактический ответ. Last-Modifiedполезен как запасной валидатор; его удобно выставляет сам Nginx для файлов с диска.
Для API‑ответов ETag можно формировать приложением (хеш полезной нагрузки) и обрабатывать If-None-Match, отдавая 304 при совпадении.

Cache-busting: три подхода и их последствия
Сравним стратегии на практике:
- Хеш в имени файла. Плюсы: лучше всего работает с любыми кэшами и CDN, легко отличить «вечные» ресурсы на уровне Nginx. Минусы: нужно, чтобы сборщик/билдер прокладывал новые пути в шаблоны (Webpack/Rollup/Vite, bundlers бэкенда,
collectstaticи пр.). - Версионные директории. Плюсы: удобно для отделения релизов (
/assets/vNNN/…), позволяет держать N релизов параллельно. Минусы: требуется аккуратный деплой и зачистка старых директорий. - Query‑параметр. Плюсы: проще встроить в CMS/шаблоны. Минусы: зависимость от настроек CDN; некоторые ускорители агрегируют кэш по пути, игнорируя query — риск коллизий.
Если вы не уверены в политике CDN, используйте хеш в имени или версионные директории. Тогда immutable абсолютно безопасен. Дополнительно про хранение версий артефактов и кэширование на объектных стораджах разобрано в материале Версионирование в S3 и CDN: хранение и кэш.
Чего делать нельзя
- Ставить
immutableна HTML, JSON, динамические страницы и API — пользователи могут «залипнуть» на старой версии. - Выдавать «вечные» заголовки ресурсам без уникального URL — получите нескончаемые отладочные танцы и принудительные инвалидации.
- Полагаться только на
ETagдля тяжёлой статики: это добавляет RTT на условные запросы и снижает выигрыш от CDN. - Даже с cache-busting удалять старые файлы сразу после релиза — браузеры и CDN ещё долго будут на них ссылаться с кешированных HTML; держите несколько релизов доступными.
CDN: кто главный — заголовки или панель
Большинство CDN по умолчанию уважают заголовки Cache-Control от origin, но в панели часто включены override‑правила. Проверьте:
- Как формируется ключ кэша: учитывает ли query‑string, cookies,
VaryиAccept-Encoding. - Не переписывает ли CDN
max-age/s-maxageили не игнорирует лиimmutable. - Включены ли механики «serve stale» при ошибках источника и как они сочетаются с вашими заголовками.
Хорошая практика — хранить максимум политики в ответах origin, а в панели держать минимальные исключения (например, принудительные правила для отдельных путей).
Интеграция в CI/CD
Правильный деплой выглядит так:
- Сборщик создаёт артефакты с контент‑хешами в именах.
- Публикуете новый набор в новую директорию/путь. Старый остаётся доступным.
- Обновляете шаблоны/манифесты, чтобы HTML ссылался на новые URL.
- Раскатываете релиз. В этот момент у пользователей в кэшах — «вечные» файлы старого релиза, но HTML быстро переведёт их на новые.
- Через безопасный интервал удаляете устаревшие артефакты.
Если работаете с CMS, где автоматическая подмена путей сложна, используйте манифесты (asset-manifest.json) или функции‑помощники, подставляющие актуальные версии.
Проверка и отладка
Быстрые команды для ручной проверки заголовков и кэширующего поведения:
# Статика с хешем: ожидаем public, max-age=31536000, immutable
curl -I https://example.org/assets/app.9f12ab.css
# HTML: ожидаем no-cache (или max-age=0, must-revalidate)
curl -I https://example.org/index.html
# Повторный запрос к «вечной» статике должен НЕ слать If-None-Match
curl -I https://example.org/assets/app.9f12ab.css
# Проверка CDN-варьирования по кодировке
curl -I -H "Accept-Encoding: gzip" https://example.org/assets/app.9f12ab.css
curl -I -H "Accept-Encoding: br" https://example.org/assets/app.9f12ab.css
В браузере смотрите DevTools → Network: статус (from disk cache), (from memory cache) и наличие/отсутствие conditional запросов. Если при Ctrl+R для «вечной» статики браузер снова валидирует ресурс — проверьте, действительно ли есть immutable, и нет ли противоречащих директив.
Частые грабли
- Дублирующийся
Cache-Controlиз разныхadd_header— проверьтеadd_header ... alwaysи порядок вложенныхlocation. - Смешение
ExpiresиCache-Controlс разными значениями — современные клиенты слушаютCache-Control, но лучше убрать противоречия. immutableна ресурсах без cache-busting — пользователи «залипают» на старом контенте, CDN отдает устаревшее неделями.- Wrong Vary: забыли
Vary: Accept-Encodingпри on‑the‑fly компрессии — кэш смешивает gzip/br/identity варианты. - Очистка CDN вместо правильного именования: инвалидации — костыль. Лучше менять URL и оставлять старые файлы доступными на время.
Расширенные приёмы
- Preload и приоритеты загрузки: если используете
Link: <...>; rel=preload; as=script, следите, чтобы URL совпадал с «вечным» и не дублировал загрузку. - Шрифты: применяйте
immutableк.woff2с хешами; добавляйтеfont-displayв CSS для UX. - Source maps: они тоже обычно хешируются и могут уезжать с «вечным» TTL; держите их доступными столько же, сколько поддерживаете релиз.
- SRI: для критичных ресурсов совместите «вечное» кэширование с подписями Subresource Integrity — детали в статье про SRI и кэширование фронтенд-ресурсов.
Шаблонный Nginx для продакшена
Соберём чуть более полный фрагмент с типовыми расширениями, Vary, и аккуратным поведением по умолчанию.
map $uri $is_hashed {
default 0;
~-[0-9a-f]{8,}\.(?:css|js|mjs|map|png|jpg|jpeg|webp|avif|gif|svg|ico|woff2?|ttf|eot|mp4|webm|ogg)$ 1;
}
map $is_hashed $cache_control_static {
1 "public, max-age=31536000, s-maxage=31536000, immutable, stale-while-revalidate=86400, stale-if-error=86400";
0 "public, max-age=600, s-maxage=1200, stale-while-revalidate=600, stale-if-error=600";
}
server {
listen 80;
server_name example.org;
# HTML и пр. динамика: валидируем каждый раз
location ~* \.(?:html|json)$ {
add_header Cache-Control "no-cache" always;
try_files $uri =404;
}
# Вся статика
location ~* \.(?:css|js|mjs|map|png|jpg|jpeg|webp|avif|gif|svg|ico|woff2?|ttf|eot|mp4|webm|ogg)$ {
add_header Cache-Control $cache_control_static always;
add_header Vary "Accept-Encoding" always;
try_files $uri =404;
}
# Остальное
location / {
add_header Cache-Control "no-cache" always;
try_files $uri $uri/ =404;
}
}
Этот шаблон хорош как стартовая точка. Под свои типы и пути его легко расширять.
FAQ: коротко о важном
- Нужно ли
immutableпри query‑версии? Да, если ваш CDN учитывает query в ключе. В противном случае используйте хеш в имени. - Нужны ли
ETag/Last-Modifiedпри «вечной» статике? Нет, не критично. Для остальных ресурсов — да. - Сколько хранить старые релизы? Минимум столько, сколько длится TTL HTML у кэшей + запас. Часто 7–30 дней достаточно.
- Что ставить на HTML SPA?
Cache-Control: no-cache(илиmax-age=0, must-revalidate), можноs-maxageдля CDN.
Итог
Надёжная схема кэширования для фронта выглядит так: внедрить cache-busting с изменением пути (хеши в именах или версионные директории), в Nginx настроить «вечные» заголовки Cache-Control с immutable только для таких URL, а для HTML и динамики — валидацию через no-cache и, при необходимости, короткий s-maxage для CDN. Добавьте тесты через curl -I и DevTools, держите несколько релизов параллельно — и кэш начнёт работать на вас: меньше запросов к origin, быстрее первая отрисовка, стабильнее пиковые нагрузки.


