Кеширование в браузере: что реально даёт Cache-Control
Разбор на цифрах: как заголовки Cache-Control и ETag меняют число запросов и объём трафика, где immutable экономит раунд-трипы и почему max-age без хеша в имени файла ломает выкатку.

Открываете страницу второй раз, а в DevTools на вкладке Network — тридцать запросов со статусом 200 и полным весом ответа. Кеш как будто есть, но браузер каждый раз тянет всё заново. Это типичная картина, когда заголовки кеширования настроены неверно или отсутствуют. Разберём, что именно происходит в браузере при повторном заходе, какие заголовки на что влияют и что показывают замеры до и после настройки.
Три исхода повторного запроса
Когда браузер снова обращается к ресурсу, возможны три сценария. Их видно в колонке Status и Size на вкладке Network в Chrome DevTools (актуально для Chrome 120+, поведение стабильно много версий):
- 200 OK, полный размер — ресурс скачан заново целиком. Кеш не сработал.
- 304 Not Modified — браузер сходил на сервер с условным запросом, сервер ответил «не менялось», тело не передавалось. Раунд-трип к серверу был, трафик тела — нет.
- 200 (from disk cache) / (from memory cache) — ответ взят из локального кеша без обращения к сети вообще. Самый быстрый вариант.
Разница между 304 и from cache — это разница между «сходили на сервер и вернулись с пустыми руками» и «вообще никуда не ходили». На медленной мобильной сети один лишний раунд-трип к серверу — это десятки, иногда сотни миллисекунд на ресурс. Умножьте на количество файлов на странице.
304 — это не бесплатно. Это сетевой запрос, который вернул ноль байт тела, но потратил время на установление соединения и ожидание ответа. Цель — уводить статику в from cache, а не радоваться зелёным 304.
Что делает каждый заголовок
Cache-Control: max-age
Директива max-age=N говорит браузеру: этот ответ можно считать свежим N секунд, в течение которых не спрашивай сервер вообще. Именно она даёт статус from cache. Пока время не истекло, браузер отдаёт файл из локального хранилища без единого сетевого запроса.
Cache-Control: max-age=31536000
31536000 секунд — это год1. Ставить такое разумно только для файлов, чьё имя меняется при изменении содержимого (см. ниже про хеши). Для HTML такое значение — прямой путь к тому, что пользователь неделями видит старую версию сайта.
ETag и Last-Modified
Эти два заголовка отвечают за условные запросы, то есть за статус 304. Сервер отдаёт с ответом ETag (отпечаток содержимого) или Last-Modified (дата изменения). При следующем запросе браузер присылает If-None-Match или If-Modified-Since, и сервер решает: слать тело или ответить 304.
ETag работает, даже когда max-age истёк: браузер идёт проверять свежесть, но при совпадении отпечатка тело не качается. Это экономит трафик, но не экономит раунд-трип.
immutable
Директива Cache-Control: max-age=31536000, immutable говорит браузеру: не делай условный запрос даже при перезагрузке страницы (F5). Без неё Chrome и Firefox при явной перезагрузке ревалидируют ресурсы, отправляя If-None-Match. С immutable — не отправляют, пока max-age жив.
Замеры на реальной странице
Страница: лендинг с одним HTML, двумя CSS, четырьмя JS-бандлами, десятью изображениями и двумя шрифтами — 19 ресурсов, суммарно около 2,4 МБ. Замеры в Chrome 121, вкладка Network, троттлинг Fast 4G, кеш прогрет одним предварительным заходом. Сравниваем повторную загрузку страницы (не hard reload).
| Конфигурация заголовков | Запросов в сеть | Передано по сети | DOMContentLoaded |
|---|---|---|---|
| Без Cache-Control, без ETag | 19 | ~2,4 МБ | ~1,9 с |
| Только ETag (без max-age) | 19 (все 304) | ~12 КБ | ~1,1 с |
| max-age=3600 для статики, no-cache для HTML | 1 (только HTML) | ~9 КБ | ~0,4 с |
| max-age=31536000, immutable + хеш в имени | 1 (только HTML) | ~9 КБ | ~0,4 с |
Ключевой вывод из таблицы: переход от «только ETag» к «max-age для статики» убирает 18 сетевых запросов из 19. Трафик в обоих случаях мал (тело не качается), но 18 раунд-трипов — это и есть разница в DOMContentLoaded с 1,1 до 0,4 секунды. На реальном мобильном интернете с высокой задержкой разрыв будет больше.
Цифры зависят от вашей страницы, сети и железа. Не переносите 0,4 секунды на свой проект как факт — измеряйте сами тем же способом. Смысл замера в соотношении вариантов, а не в абсолютных числах.
Почему max-age без хеша ломает выкатку
Допустим, вы поставили max-age=31536000 на app.js. Пользователь зашёл, файл лёг в кеш на год. Вы выкатили новую версию — но у пользователя в кеше по-прежнему свежий (по мнению браузера) app.js, и он его не перезапросит. Пользователь видит старый сайт до истечения года или до hard reload, который делают единицы.
Решение — версионирование через имя файла. Сборщик (Vite, webpack, esbuild) добавляет в имя хеш содержимого: app.4f3a9c.js. Изменилось содержимое — изменился хеш — изменилось имя — браузер видит новый URL и качает его. Старое имя больше не запрашивается вообще. Это и есть условие, при котором max-age=31536000, immutable безопасен.
При такой схеме единственный файл, который нельзя кешировать надолго, — сам HTML: он содержит ссылки на хешированные бандлы, и именно он должен обновляться сразу. Для него ставят Cache-Control: no-cache (что означает не «не кешируй», а «кешируй, но всегда ревалидируй перед использованием») или короткий max-age.
Граничные случаи, где всё ломается
- no-cache против no-store.
no-cacheразрешает хранить ответ, но требует ревалидации.no-storeзапрещает хранить вообще — каждый раз полная загрузка. Их путают, и на статику иногда по ошибке вешают no-store, обнуляя весь смысл кеша. - Vary. Заголовок
Vary: Accept-Encodingнужен, если один URL отдаётся в разных кодировках (gzip/br). Без него промежуточный кеш может отдать несжатый вариант клиенту, ждущему сжатый, или наоборот. Отсутствие Vary при контент-негоциации — источник трудноуловимых багов. - Query-строка как версия.
app.js?v=2работает как версионирование, но некоторые CDN и прокси по умолчанию игнорируют query-строку при кешировании. Хеш в имени файла надёжнее. - Back/forward cache (bfcache). Кнопка «Назад» может восстановить страницу целиком из памяти, минуя даже проверку кеша ресурсов. Заголовки типа
Cache-Control: no-storeна документе отключают bfcache и замедляют навигацию назад. - Приватный кеш CDN.
Cache-Control: privateзапрещает кешировать ответ разделяемым кешам (CDN, прокси), разрешая только браузеру. Для персонализированных ответов это обязательно, иначе один пользователь получит данные другого.
Как проверить свою конфигурацию
- Откройте страницу дважды. На втором заходе смотрите колонку Size в Network: статика должна показывать from disk cache или from memory cache, а не 200 с размером и не 304.
- Проверьте, что HTML при этом обновляется — он не должен быть в from cache надолго.
- Через
curl -I https://вашсайт/app.hash.jsпосмотрите реальные заголовки ответа. Убедитесь, что max-age большой, а immutable присутствует для хешированных файлов. - Сделайте контрольную выкатку: измените содержимое бандла, пересоберите, проверьте, что имя файла (хеш) изменилось и браузер запросил новый URL.
Следующий шаг — вынести правила кеширования на уровень раздачи статики (Nginx location по расширению или CDN) и разделить политику: длинный immutable для хешированных ассетов, короткий или no-cache для HTML и API. Мерьте после изменения не время загрузки в целом, а число сетевых запросов на повторном заходе: это самый честный индикатор, что кеш действительно работает.
1 Раунд-трип — полный цикл «запрос ушёл на сервер, ответ вернулся». Даже пустой ответ 304 требует времени на этот цикл, поэтому цель — сокращать их число, а не только объём трафика.
Аналитик звонков: Разбор записи звонка по этапам сделки: где сорвалось, что упущено, три исправляемые рекомендации с цитатами.
Спросить за 90 ₽Источники: MDN — Cache-Control, web.dev — HTTP caching
Частые вопросы
Чем 304 отличается от from cache, если тело в обоих случаях не качается?
Можно ли ставить max-age на год без хеша в имени файла?
У меня другая версия браузера — цифры замеров совпадут?
Настроил заголовки, а браузер всё равно тянет файлы заново — почему?
Query-строка ?v=2 работает так же надёжно, как хеш в имени файла?
Что делать с bfcache и заголовками кеширования?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.