Почти любому сайту нужны два одинаковых правила. Файлы с хэшем содержимого в имени - app.9f2c1b.js - получают Cache-Control: public, max-age=31536000, immutable, потому что имя меняется вместе с содержимым, и браузеру больше никогда не нужно спрашивать. HTML получает Cache-Control: no-cache: страница сохраняется, но перепроверяется, поэтому деплой виден сразу, а повторный визит стоит одного дешёвого ответа 304 вместо полной загрузки.
Это девяносто процентов веб-кэширования. Остальная часть статьи - оставшиеся десять: что на самом деле означает каждая директива, почему ваши заголовки игнорируются и чем CDN отличается от браузера.
Кэши между вашим сервером и посетителем#
Ответ может храниться в большем числе мест, чем представляет себе большинство, и каждое подчиняется немного своим правилам.
Важно различие между приватным и общим кэшем. Кэш браузера принадлежит одному человеку, поэтому может хранить страницу его аккаунта. CDN общий для всех, поэтому не должен, и директива private - это способ сказать об этом. Перепутав их, вы получите подтверждение заказа одного клиента, показанное другому, - скверный день, который начинается с заголовка.
Ваш обратный прокси стоит посередине и обычно не кэширует вообще, пока вы это не настроите. На RE:NODE слот прокси завершает TLS и пересылает запросы на ваш сервер; решения о кэшировании остаются за вашим приложением и за тем CDN, который вы поставите перед ним.
Cache-Control: директива за директивой#
Cache-Control - это заголовок ответа со списком через запятую. Вот директивы, которые заслуживают внимания:
| Директива | Кого касается | Что означает |
|---|---|---|
max-age=600 | Все кэши | Свежий 600 секунд с этого момента |
s-maxage=600 | Только общие кэши | Переопределяет max-age для CDN и прокси |
public | Все кэши | Можно хранить, даже если запрос был аутентифицирован |
private | Только браузер | Общий кэш не должен его хранить |
no-cache | Все кэши | Хранить, но перепроверять перед каждым повторным использованием |
no-store | Все кэши | Вообще не записывать |
must-revalidate | Все кэши | После устаревания никогда не отдавать без проверки |
immutable | Браузер | Не перепроверять, даже при перезагрузке |
stale-while-revalidate=60 | Все кэши | Отдавать устаревший 60 с, пока загружается свежий |
stale-if-error=86400 | Все кэши | Отдавать устаревший, если источник отказывает |
Две из них понимают неправильно постоянно. `no-cache` не означает «не кэшировать». Это значит «кэшировать и всегда спрашивать сначала», а для HTML это именно то, что нужно: круг туда и обратно крошечный, когда ничего не изменилось. no-store - та, что означает «не держать копию», и предназначена для ответов, которые не должны попадать на диск, - банковской выписки, одноразового токена, страницы сброса пароля.
immutable - директива, которая делает хэшированные ресурсы по-настоящему бесплатными при повторном визите. Без неё перезагрузка в браузере перепроверяет каждый закэшированный файл, и вы платите одним обменом на каждый ресурс, чтобы услышать, что ничего не изменилось. С ней браузер вовсе пропускает проверку на весь срок max-age. Она безопасна только для URL, содержимое которого не может измениться, а именно это и гарантирует хэш содержимого.
stale-while-revalidate - самая дешёвая победа в производительности, которую большинство сайтов так и не включает. max-age=60, stale-while-revalidate=600 означает, что закэшированная страница отдаётся мгновенно в худшем случае одиннадцать минут, а обновление идёт в фоне, а не на глазах у посетителя.
Если вы не отправляете никаких заголовков кэширования, кэшам разрешено гадать. RFC 9111 допускает эвристическую свежесть на основе Last-Modified - обычно десять процентов времени с последнего изменения документа, - так что файл, изменённый год назад, может кэшироваться на недели чем-то на пути. Молчание - это не то же самое, что «не кэшировать».
ETag, Last-Modified и ответ 304#
Свежесть говорит, как долго кэш может использовать ответ, не спрашивая. Валидация - это то, что происходит, когда он всё же спрашивает.
Last-Modified: Wed, 10 Sep 2026 09:41:12 GMT- метка времени с точностью до секунды.ETag: "6f9a2c-1b4d"- непрозрачный токен, который меняется вместе с содержимым.W/"..."обозначает слабый тег: семантически эквивалентный, но не побайтово идентичный.
При следующем запросе браузер отправляет If-Modified-Since или If-None-Match со значением, которое у него есть. Если ничего не изменилось, сервер отвечает 304 Not Modified с заголовками и без тела, и браузер использует уже имеющуюся копию.
$ curl -sI https://example.com/style.css | grep -i -E "etag|cache-control|last-modified"$ curl -sI -H 'If-None-Match: "6f9a2c-1b4d"' https://example.com/style.css | head -1HTTP/2 304304 экономит байты, но не обмен запросом. При задержке соединения в 120 мс сорок ресурсов, которые все возвращают 304, всё равно дают страницу, которая кажется медленной. Это и есть весь довод за длинный max-age с immutable для файлов с отпечатком: быстрее всего тот запрос, которого не было.
nginx создаёт ETag для статических файлов автоматически из времени изменения и размера, и он стабилен между серверами, пока у файлов одинаковые метки времени. Деплой, который перезаписывает каждый файл с новым mtime, обесценивает каждый ETag на сайте, даже там, где содержимое идентично, поэтому, когда это важно, применяйте способ копирования, сохраняющий метки времени. Прикладные фреймворки обычно хэшируют тело ответа, что правильно, но стоит CPU на каждый запрос.
Стратегия двух уровней#
Почти любой сайт должен прийти вот к этому:
| Что | Cache-Control | Почему |
|---|---|---|
| HTML-страницы | public, no-cache | Всегда свежие, дешёвый 304 при повторе |
| CSS, JS, шрифты с хэшем | public, max-age=31536000, immutable | Имя меняется вместе с содержимым |
| CSS и JS без хэша | public, max-age=3600 | Вы не можете доказать, что они не менялись |
| Загруженные изображения | public, max-age=2592000 | Редко заменяются на месте |
| Страницы для вошедших | private, no-store | Никогда в общем кэше |
| Ответы API | private, max-age=0 или no-store | Решайте для каждого эндпоинта, не гадайте |
Год - 31536000 секунд - это условный максимум для max-age, и от большего числа выгоды нет. Если ваша сборка уже создаёт имена файлов с хэшем, как любой современный сборщик, то две верхние строки покрывают весь ваш сайт; блоки nginx в контексте есть в статье хостинг статических сайтов.
Уровень, который создаёт проблемы, - третий: таблица стилей по фиксированному URL, которую вы правите на месте. Безопасного долгого кэша для неё нет, потому что часть посетителей будет держать старую копию столько, сколько вы им велели. Решение не в более коротком кэше, а в другом URL. Любой инструмент сборки умеет добавлять отпечатки к результату; если вы пишете HTML вручную, добавьте версию в строку запроса - style.css?v=7 - и меняйте её при правке. Строки запроса входят в ключ кэша для браузеров и для большинства CDN, хотя некоторые CDN можно настроить их игнорировать, и тогда ваш способ сброса кэша превращается в пустую операцию.
Vary, cookie и заголовки, отключающие кэширование#
Vary сообщает кэшам, какие заголовки запроса меняют ответ. Он необходим и опасен в равной мере, потому что каждое значение умножает число копий, которые должен хранить кэш.
Vary: Accept-Encoding- правильно и обязательно, когда вы сжимаете. Без него кэш может отдать сжатое gzip тело клиенту, который его не просил.Vary: Cookie- формально верно для страницы, различающейся по пользователям, а на практике это значит, что общий кэш никогда не получает попаданий, потому что у каждого посетителя свой cookie.Vary: User-Agent- дробит кэш на тысячи вариантов. Избегайте.
Cookie - ещё один тихий убийца кэша. Ответ с Set-Cookie не может быть сохранён общим кэшем для повторного использования другими людьми, и большинство CDN просто обходят кэш, заметив его. Это правильное поведение по умолчанию, и поэтому скрипт аналитики или согласия, который на стороне сервера ставит cookie на каждой странице, может вдвое снизить долю попаданий в кэш, и никто этого не заметит.
PHP делает это с вами по умолчанию. Первый вызов session_start() отправляет для всего ответа:
Expires: Thu, 19 Nov 1981 08:52:00 GMTCache-Control: no-store, no-cache, must-revalidatePragma: no-cacheЭто session.cache_limiter делает свою работу, и это правильно для страницы с чьим-то аккаунтом. Это неправильно для публичной страницы, которая по случайности запускает сессию без причины. Либо не запускайте сессию на страницах, которым она не нужна, либо задайте session_cache_limiter('public') до session_start() и берите ответственность за то, что делаете.
WordPress и большинство кэшей страниц CMS следуют той же логике: всё с cookie входа полностью обходит кэш, поэтому администратор, тестирующий сайт, видит медленный путь и сообщает, что кэширование не работает. Тестируйте в приватном окне. О слоях в этом конкретном случае рассказано в статье скорость WordPress и кэширование.
CDN и общие кэши#
CDN - это общий кэш во многих местах. Он читает те же заголовки, с двумя дополнениями, о которых стоит знать.
s-maxage применяется только к общим кэшам, что позволяет держать HTML свежим в браузерах и кэшированным на границе: Cache-Control: public, max-age=0, s-maxage=600, stale-while-revalidate=3600. Браузеры перепроверяют каждый раз, CDN отдаёт из своей копии десять минут и обновляет её в фоне, а ваш источник видит долю трафика. Для сайта, чьи страницы одинаковы для всех, это самое большое изменение, которое можно сделать.
CDN-Cache-Control - целевой заголовок, который читают только CDN, поэтому вы можете дать им иные указания, не трогая то, что видят браузеры. Старая инфраструктура для той же цели использует Surrogate-Control. Поддержка различается, так что проверьте документацию своего провайдера, прежде чем полагаться на него: что делает с этими заголовками распространённый вариант, описано в статье Cloudflare для сайтов и игровых серверов.
К CDN прилагаются две рабочие привычки. Первая: знайте, как очищать кэш по URL и по тегу, и вносите очистку в скрипт деплоя, а не в инструкцию, которую никто не открывает. Вторая: читайте заголовок статуса кэша, который присылает ваш провайдер, - HIT, MISS, EXPIRED, BYPASS, - потому что он превращает вопрос «работает ли кэширование» из спора в факт. Если вы видите BYPASS, ищите cookie.
CDN не исправляет медленный источник для некэшированных запросов. Первый посетитель каждой страницы в каждом расположении всё равно ждёт ваш сервер, как и каждый запрос, который cookie делает некэшируемым. Какая часть ожидания в вашей власти, разобрано в статье TTFB, Core Web Vitals и хостинг.
Как задать заголовки#
В nginx статические ресурсы - по расширению, а HTML - отдельно:
location ~* \.(?:css|js|woff2|avif|webp|png|jpg|svg)$ { expires 1y; add_header Cache-Control "public, max-age=31536000, immutable" always; access_log off;}location ~* \.html$ { add_header Cache-Control "public, no-cache" always;}expires 1y; задаёт и устаревший заголовок Expires, и соответствующий max-age, поэтому явный add_header идёт следом: вам нужны дополнительные директивы.
Ловушка nginx, в которую попадают все: директивы add_header наследуются из внешнего блока, только если в текущем блоке нет собственных. Добавьте хоть один add_header внутри location - и каждый заголовок, заданный в родительском блоке server, исчезнет из этих ответов, включая ваши заголовки безопасности. Если вы используете add_header в location, повторите всё, что задавал родитель. Флаг always заставляет заголовок применяться и к ответам с ошибкой, а не только к 2xx и 3xx.
Из PHP задавайте заголовок до любого вывода и помните, что настройки буферизации вывода определяют, насколько рано это «до любого вывода» на самом деле, - те, что кусаются, описаны в статье настройки php.ini, которые важны:
header('Cache-Control: public, max-age=300, stale-while-revalidate=600');В прикладном фреймворке пользуйтесь его помощниками для ответа, а не сырыми заголовками, потому что фреймворк может добавить свои позже, а побеждает последний записанный.
Проверка того, что вы на самом деле отправляете#
Никогда не верьте конфигурации; читайте то, что идёт по сети. Одна команда покажет большую часть:
$ curl -sI https://example.com/ | grep -i -E "cache-control|etag|age|vary|set-cookie"На что смотреть, по порядку:
- `Set-Cookie` на публичной странице. Почти всегда случайность, и она отключает общее кэширование.
- `Age`. Его наличие означает, что что-то перед вами отдало сохранённую копию, а значение - это возраст этой копии.
- `Vary`. Всё, кроме
Accept-Encoding, требует причины. - Противоречащие директивы.
no-cacheвместе сmax-age=3600допустимо и запутывает;no-storeпобеждает всё. - Панель сети в инструментах разработчика браузера. Столбец размера с надписью «disk cache» или «memory cache» и нулевым сетевым временем - вот как выглядит работающий кэш. Помните, что жёсткая перезагрузка обходит кэш и ничего не говорит о реальном опыте.
Когда изменение заголовков вроде бы ничего не даёт, проверяйте по порядку: не отдаёт ли стоящий впереди CDN старую копию, не держит ли ваш браузер копию с до изменения и не переопределяет ли вас блок location ниже в конфигурации. В восьмидесяти процентах случаев это одно из трёх.
FAQ#
В чём разница между no-cache и no-store?
no-cache сохраняет ответ и перепроверяет его перед каждым повторным использованием, так что вы по-прежнему получаете 304 и быструю страницу. no-store запрещает записывать его куда бы то ни было. Используйте no-cache для меняющегося HTML и no-store только для действительно чувствительных ответов - он к тому же отключает кэш браузера для навигации назад и вперёд, отчего переходы кажутся медленнее.
Что использовать: ETag или Last-Modified?
Оба, если это ничего не стоит. У Last-Modified точность одна секунда, и он годится для файлов; ETag точен и работает для сгенерированных ответов. Отправка обоих позволяет клиенту использовать тот, который он предпочитает. Не отключайте ETag, если у вас нет конкретной проблемы с несколькими серверами, которую вы измерили.
Почему мой CSS всё ещё старой версии после деплоя?
Потому что вы велели браузерам его хранить. Если URL не изменился, а вы отправили длинный max-age, единственное лекарство - время или новый URL. Добавляйте к ресурсам отпечатки, чтобы это не повторилось, и оставляйте длинный кэш для файлов, чьи имена кодируют их содержимое.
Помогает ли кэширование сайту с очень малым трафиком?
Оно помогает повторным посетителям и каждой загрузке страницы после первой, а это большая часть того, что видит посетитель. Первому запросу на холодном кэше оно не помогает. Для небольшого сайта обычно большая выгода - быстрый сам источник плюс сжатие.
Как долго кэшировать изображения?
Тридцать дней - разумное значение по умолчанию для загруженных медиа, год - если в имени файла есть хэш. Типичная беда - заменить изображение на месте, а старое продолжает жить, поэтому лучше загружать новый файл под новым именем, чем перезаписывать старый.
Влияют ли эти заголовки на позиции в поиске?
Не напрямую. Они влияют на то, как быстро загружаются страницы у реальных посетителей, а это измеряется в поле и через Core Web Vitals входит в сигналы ранжирования. Кэшировать стоит ради пользователей; ранжирование - побочный эффект.




Комментарии
Полностью анонимно: без аккаунта, без почты, без cookie. Мы храним имя, которое вы ввели, текст и время - больше ничего. Количество ссылок ограничено, разметка не отображается.