HTTP-кеширование без мифов: Cache-Control, ETag и версии ассетов
Разбираем HTTP-кеширование по спецификации: директивы Cache-Control, валидацию через ETag, immutable-версионирование ассетов, stale-while-revalidate и роль CDN.

Разработчики часто относятся к HTTP-кешированию как к чёрной магии: то браузер «не видит» новую версию файла, то CDN отдаёт старую страницу после деплоя, то мучительная отладка заканчивается советом просто добавить случайное число в URL. На самом деле вся механика описана в спецификации HTTP и укладывается в понятный набор правил, если разделить две разные задачи: не скачивать файл повторно, если он не изменился, и не скачивать файл повторно вообще, даже не спрашивая сервер, если он точно не может измениться.
Cache-Control: директивы, которые реально используются
Заголовок Cache-Control управляет свежестью ответа. max-age=3600 говорит браузеру и промежуточным кешам, что ответ можно использовать без повторного запроса к серверу в течение часа. Директива public разрешает кешировать ответ любому промежуточному кешу, private ограничивает кеширование браузером конкретного пользователя, что важно для персонализированных ответов с данными авторизации. no-cache не означает «не кешировать», это частая путаница: ответ кешируется, но перед каждым использованием кеш обязан провалидировать его с сервером. no-store, это настоящий запрет кеширования, подходит для чувствительных данных.
| Директива | Что делает |
|---|---|
| max-age=N | Считать ответ свежим N секунд без обращения к серверу |
| public / private | Разрешить или ограничить кеширование промежуточными узлами |
| no-cache | Кешировать, но обязательно валидировать перед каждым использованием |
| no-store | Не кешировать совсем |
| immutable | Файл никогда не изменится по этому URL, не проверять даже при обновлении страницы |
ETag и Last-Modified: валидация вместо повторной загрузки
Когда ответ помечен как устаревший или с директивой no-cache, браузер не скачивает файл заново на всякий случай, он отправляет условный запрос. Last-Modified хранит дату последнего изменения ресурса, и повторный запрос с заголовком If-Modified-Since позволяет серверу ответить 304 Not Modified без тела, если файл не менялся. ETag работает точнее: это хеш или версия содержимого, и запрос с If-None-Match сравнивает его на сервере. ETag предпочтительнее, когда время модификации ненадёжно, например, после деплоя на нескольких серверах файлы могут иметь разные даты изменения при одинаковом содержимом, а Last-Modified дешевле вычисляется и достаточен для статики, где это не критично.
GET /styles.css HTTP/1.1
If-None-Match: "a1b2c3d4"
HTTP/1.1 304 Not Modified
ETag: "a1b2c3d4"immutable и версионирование ассетов
Самый надёжный способ кешировать статику агрессивно и без рисков показать пользователю старый файл, это версионировать имя файла по содержимому: styles.a1b2c3.css вместо styles.css, где хеш меняется автоматически при каждой сборке, так работают современные бандлеры. Для такого файла можно безопасно поставить Cache-Control: max-age=31536000, immutable, то есть кешируй год и никогда не перепроверяй, потому что при изменении содержимого изменится и сам URL, а старый файл просто перестанет на кого-либо ссылаться. HTML-документ, который ссылается на эти файлы, наоборот, кешировать агрессивно нельзя: именно он должен каждый раз подхватывать новые хеши, поэтому для HTML обычно используют no-cache или короткий max-age.
- Статика с хешем в имени: max-age=31536000, immutable
- HTML-страницы: no-cache или max-age=0, must-revalidate
- API-ответы с часто меняющимися данными: private, no-store либо короткий max-age в зависимости от чувствительности данных
stale-while-revalidate
Директива stale-while-revalidate решает конкретный компромисс: пользователь получает мгновенный ответ из кеша, даже если он формально устарел, а в фоне браузер или CDN обновляет копию для следующего запроса. Это удобно для контента, где небольшая задержка актуальности не критична, блог, каталог товаров, но важна скорость отклика. Запись вида max-age=60, stale-while-revalidate=3600 означает: до 60 секунд отдаём без вопросов, следующие 3600 секунд отдаём устаревшую копию мгновенно, но параллельно обновляем её в фоне, и только после этого окна идём за полноценным новым ответом синхронно.
CDN и уровни кеша: кто кеширует что
В реальной инфраструктуре ответ проходит несколько уровней кеша: кеш браузера на устройстве пользователя, кеш CDN на граничном узле, иногда промежуточный кеш на уровне обратного прокси перед origin-сервером. Каждый уровень может иметь свои правила: CDN часто игнорирует private и кеширует только public-ответы, а время жизни на CDN можно переопределить отдельным заголовком, отдельным от max-age для браузера. После деплоя важно понимать, на каком уровне застряла старая версия: запрос с выводом заголовков покажет правило кеширования, а дополнительные служебные заголовки подскажут, был ли ответ отдан из кеша CDN или долетел до origin.
Vary и кеширование персонализированных ответов
Заголовок Vary указывает кешу, что ответ на один и тот же URL может отличаться в зависимости от значения другого заголовка запроса, и кеш обязан хранить отдельную копию для каждого варианта. Самый частый пример, Vary: Accept-Encoding, когда сервер отдаёт один и тот же ресурс по-разному сжатым (gzip, brotli) в зависимости от того, что поддерживает клиент; без этого заголовка промежуточный кеш может один раз сохранить сжатую версию и отдать её клиенту, который сжатие не поддерживает, что сломает страницу. Похожая ситуация возникает с Vary: Cookie или Vary: Authorization для персонализированных ответов: такой заголовок технически защищает от утечки чужих данных через общий кеш, но одновременно резко снижает эффективность кеширования, потому что почти каждый пользователь получает свой уникальный вариант сессии.
На практике персонализированные фрагменты страницы лучше не кешировать на уровне всего HTTP-ответа, а выносить в отдельные запросы (например, через fetch после загрузки основной статической разметки) или в edge-логику, которая подставляет персональные данные уже на границе CDN без обращения к origin-серверу. Это позволяет держать основной HTML-документ кешируемым и быстрым для всех пользователей одновременно, а персональную часть, имя пользователя, содержимое корзины, обновлять отдельно и только для конкретной сессии.
Отдельно стоит сказать про кеширование на уровне service worker в браузере, которое добавляет ещё один слой поверх HTTP-кеша и подчиняется собственной логике, заданной кодом самого приложения, а не только заголовками сервера. Service worker может явно решить обслужить запрос из локального кеша даже тогда, когда HTTP-заголовки сервера предполагали бы повторный запрос, и наоборот, игнорировать агрессивный max-age и всегда идти в сеть для конкретного типа ресурса. При отладке «застрявшей» версии сайта в современном браузерном приложении стоит проверять не только Cache-Control и CDN, но и наличие активного service worker, который может быть источником несоответствия независимо от правильно настроенных HTTP-заголовков.
Наконец, при проектировании новой системы кеширования полезно с самого начала документировать политику в одном месте: какие типы ответов получают какой Cache-Control, где используется версионирование по содержимому и где stale-while-revalidate. Без такой документации правила кеширования постепенно расходятся по разным частям кодовой базы, и через год никто в команде не может объяснить, почему конкретный эндпоинт кешируется именно так.
- Проверьте заголовок Cache-Control в реальном ответе сервера, а не только в коде
- Для статики используйте версионирование по содержимому и immutable
- Для HTML используйте короткий max-age или no-cache, чтобы подхватывать новые ссылки на ассеты
- При проблеме с застрявшим кешем после деплоя проверяйте отдельно браузер, CDN и origin, а не только один из уровней
Частые вопросы
Чем отличается no-cache от no-store?
no-cache разрешает кешировать ответ, но требует обязательной валидации с сервером перед каждым использованием сохранённой копии, то есть повторный запрос к серверу всё равно будет, просто более лёгкий, с возможным ответом 304. no-store запрещает сохранять ответ вообще, каждый запрос идёт за полным содержимым.
Нужно ли вручную добавлять версию в URL ассетов, если бандлер уже это делает?
Нет, если бандлер уже вставляет хеш содержимого в имя файла, добавлять дополнительный параметр с версией избыточно. Ручное версионирование нужно только для файлов, которые собираются не через бандлер и не получают хеш автоматически.
Почему после деплоя пользователи иногда всё равно видят старую версию сайта?
Чаще всего причина в том, что HTML-страница закешировалась с длинным max-age на уровне CDN или браузера и продолжает ссылаться на старые хешированные файлы ассетов. Решение, держать для HTML короткий max-age или no-cache, а агрессивное кеширование оставлять только для версионированной статики.



