Зачем вообще разбираться в caching headers
HTTP-кэширование — один из самых дешёвых способов ускорить сайт: меньше запросов, меньше трафика, ниже нагрузка на PHP/бекенд, быстрее LCP и TTFB. Но оно же часто становится источником «магии»: у одного пользователя всё обновилось, у другого нет, а в логах вдруг много 304 Not Modified и непонятно — это хорошо или плохо.
Ниже разберём три ключевых механизма: Cache-Control (политика хранения), ETag и Last-Modified (валидаторы), а также понятие revalidation — когда клиент не скачивает заново контент, а проверяет, не изменился ли он.
Фокус — практика: как выбирать значения, как не сломать обновления, какие комбинации подходят для статики, HTML и API, и что именно настраивать в Nginx (в том числе expires).
Модель кэша: свежесть и валидация
Кэширование в HTTP состоит из двух независимых решений:
- Сколько времени ответ считается свежим (freshness). Это задаётся
Cache-Controlи/илиExpires. - Как проверять изменения (validation). Это делают
ETag/If-None-MatchиLast-Modified/If-Modified-Since. Процесс проверки и называется revalidation.
Если ответ ещё «свежий», браузер может вообще не ходить на сервер. Если «протух» — обычно делает условный запрос (conditional request) и получает либо новую версию (200), либо подтверждение, что ресурс не менялся (304).
304 Not Modifiedпочти всегда означает экономию трафика и ускорение. Но если 304 слишком много на ресурсы, которые могли бы быть «долго свежими» (immutable-статика), вы теряете задержку на лишний RTT до сервера или CDN.

Cache-Control: главный рубильник политики
Cache-Control — это набор директив. Не нужно помнить все: достаточно тех, что реально определяют поведение браузера и CDN.
Ключевые директивы Cache-Control
max-age=SECONDS— сколько секунд ответ считается свежим.public— можно кэшировать в общих кэшах (CDN/прокси).private— кэшировать можно, но только в браузере (shared cache не должен).no-cache— хранить можно, но перед использованием нужно делать revalidation.no-store— не хранить вообще (обычно для приватных данных).must-revalidate— если устарело, использовать без проверки нельзя.stale-while-revalidate— можно отдать «чуть устаревшее», пока идёт фоновая проверка.immutable— ресурс не изменится в течение срока свежести (идеально для версионированной статики).
Типовые профили Cache-Control
1) Версионированная статика (например, app.3f2c1.js, styles.98ab.css): цель — чтобы браузер месяцами не обращался к серверу и не делал revalidation.
Cache-Control: public, max-age=31536000, immutable
2) Неверсионированная статика (например, /logo.svg без хэша в имени): даём кэш, но не «запираем» себя без возможности быстро обновить файл.
Cache-Control: public, max-age=3600
3) HTML-страницы (часто меняются, зависят от куки/логина): безопасная стратегия — хранить, но всегда проверять.
Cache-Control: no-cache
Это приведёт к revalidation на каждое открытие (и часто к 304 Not Modified), но защитит от раздачи устаревшего HTML.
4) Персональные ответы (кабинет, корзина, данные профиля):
Cache-Control: private, no-store
Иногда достаточно private, no-cache, но no-store проще и безопаснее для чувствительных данных.
Max-Age vs Expires и почему обычно достаточно Cache-Control
Expires — наследие HTTP/1.0: он задаёт абсолютную дату и зависит от корректности часов. Cache-Control: max-age задаёт относительное время и почти всегда предпочтительнее.
При этом в Nginx директива expires часто выставляет и Expires, и Cache-Control. Это нормально, если вы контролируете итоговые заголовки.
ETag: точная проверка «та же самая версия или нет»
ETag — идентификатор версии ресурса. Сервер отправляет его в ответе, а клиент при следующем обращении может прислать If-None-Match. Если значение совпадает, сервер отвечает 304 Not Modified без тела.
Сильные и слабые ETag
В HTTP есть понятия «сильных» и «слабых» ETag. Сильный означает «байт-в-байт тот же ресурс», слабый — «семантически эквивалентен» (например, отличия только в пробелах). На практике веб-серверы чаще отдают сильные ETag для файлов.
Грабли ETag в кластере и за балансировщиком
Классическая проблема: ETag генерируется на основе inode/mtime/размера файла или иных локальных атрибутов, и на разных нодах получается разный. Тогда клиент может попасть на другой сервер и получить «ETag не совпал» даже при одинаковом контенте — итогом будут лишние 200 вместо ожидаемых 304.
Что обычно делают на практике:
- Для версионированной статики — длинный
max-age+immutableи ставка на отсутствие revalidation вообще. - Если нужен ETag для API/HTML — генерируют его на уровне приложения (например, из версии данных или хэша тела ответа) или обеспечивают одинаковую генерацию на всех нодах.
- Иногда ETag на статику в Nginx отключают, чтобы избежать проблем в смешанных окружениях. Это не «ускорение», а отказ от одного из валидаторов.
Если вы часто переключаете трафик между серверами (балансировщик, несколько пулов, разные зоны), полезно заранее продумать миграции без сюрпризов. По теме переездов и переключений пригодится материал про миграцию сайта без простоя.
Last-Modified: проще, но менее точный валидатор
Last-Modified говорит, когда ресурс менялся в последний раз. Клиент посылает If-Modified-Since, сервер сравнивает и может вернуть 304 Not Modified.
Плюсы:
- Просто и понятно, хорошо работает для файлов и многих API.
- Меньше проблем в кластере, если время синхронизировано, а контент действительно одинаковый.
Минусы:
- Точность часто до секунды: если вы дважды обновили ресурс за одну секунду, клиент может не увидеть изменения.
- Для динамических ответов «время последнего изменения» бывает сложно определить корректно.
Хорошая практика: для динамических API отдавать
ETag, аLast-Modifiedиспользовать дополнительно только если у вас есть надёжный timestamp изменения данных.
Revalidation на практике: как рождается 304 Not Modified
Сценарий выглядит так:
- Клиент получил ответ, но срок свежести истёк (или
max-age=0). - Клиент отправляет условный запрос:
If-None-Matchи/илиIf-Modified-Since. - Сервер сравнивает валидатор и отвечает
304или200.
304 Not Modified— тело не отправляется, клиент использует кэш.200 OK— если ресурс изменился, возвращается новое тело.
Важно: 304 всё равно стоит CPU/IO на сервере (проверка файла/метаданных, логика приложения, логи). Поэтому для статики цель обычно не «много 304», а «мало запросов вообще» — через длительную свежесть и versioning.

Nginx: expires и настройка кэша для статики
В Nginx чаще всего используют сочетание expires и add_header Cache-Control. Нюанс: add_header без параметра always по умолчанию добавляет заголовки не ко всем кодам ответа. Для статики это обычно не критично, но полезно помнить при отладке.
Профиль «год + immutable» для версионированных ассетов
Если сборщик (Webpack/Vite/Rollup) добавляет хэш в имя файла — это лучший вариант.
location ~* \.(?:css|js|mjs|png|jpg|jpeg|gif|svg|ico|webp|avif|woff2?)$ {
expires 1y;
add_header Cache-Control "public, max-age=31536000, immutable";
}
expires 1y; уже задаёт кэширование, но явный Cache-Control делает поведение предсказуемым (и добавляет immutable).
Профиль «час» для неверсионированной статики
location ~* \.(?:png|jpg|jpeg|gif|svg|ico)$ {
expires 1h;
add_header Cache-Control "public, max-age=3600";
}
Если нужно обновлять логотип/иконки без смены URL — держите max-age небольшим или внедряйте версионирование.
Что делать с HTML
Для HTML часто выбирают «всегда проверять» (revalidation), чтобы пользователи не видели вчерашнюю версию страницы:
location / {
add_header Cache-Control "no-cache";
}
Если у вас много статики и HTML отдельно, обычно HTML не попадает под правила expires по расширениям. Но будьте аккуратны с глобальными настройками на весь сервер.
ETag в Nginx: включать или выключать
По умолчанию Nginx обычно отдаёт ETag для статических файлов. Управляется это директивой etag (в контексте http/server/location).
etag on;
Если у вас несколько нод и вы видите, что одинаковый URL отдаёт разные ETag, иногда проще отключить ETag на уровне Nginx и опираться на Last-Modified плюс разумный max-age:
etag off;
Делайте так только осознанно: отключение ETag не делает кэш «лучше», оно лишь убирает один механизм валидации.
Как не убить кэш авторизацией, куками и Vary
Одна из самых частых причин «кэш не работает» — лишние куки. Браузер может кэшировать и с куками, но CDN и промежуточные прокси часто перестраховываются: если есть Set-Cookie, публичный кэш может отказаться хранить ответ или будет делать это иначе.
Если у вас публичные страницы, старайтесь:
- Не выставлять
Set-Cookieтам, где это не нужно. - Разделять домены/поддомены для статики и приложения, если проект большой.
- Контролировать
Vary(часто нуженVary: Accept-Encoding; для API — осторожнее сVary: Authorization).
Vary говорит кэшу, что разные значения заголовка означают разные варианты ответа. Это полезно, но опасно: слишком широкий Vary фрагментирует кэш и снижает hit rate.
Частые ошибки и быстрые проверки
Ошибка 1: «Поставили max-age=31536000 на всё подряд»
Так можно «закэшировать» HTML и сломать обновления контента для пользователей. Длинный max-age уместен почти только для версионированных файлов.
Ошибка 2: ETag разный на разных нодах
Если у вас несколько серверов, проверьте: одинаковый файл по одному URL должен отдавать одинаковый ETag. Если нет — либо делайте кэширование через versioning, либо унифицируйте генерацию ETag.
Ошибка 3: много 304 там, где могли бы быть «без запросов»
Если ассеты часто возвращают 304 Not Modified, вероятно, у них короткий max-age или стоит no-cache. Для неизменяемой статики лучше сделать длинную свежесть и immutable.
Проверка заголовков с curl
Минимальный набор команд, чтобы глазами увидеть caching headers (замените домен на свой):
curl -I https://example.com/
curl -I https://example.com/assets/app.3f2c1.js
Чтобы вручную проверить revalidation с ETag:
curl -I https://example.com/assets/app.3f2c1.js
curl -I -H 'If-None-Match: "PASTE_ETAG_HERE"' https://example.com/assets/app.3f2c1.js
И аналогично для Last-Modified:
curl -I https://example.com/assets/logo.svg
curl -I -H 'If-Modified-Since: Tue, 10 Oct 2023 10:00:00 GMT' https://example.com/assets/logo.svg
Смотрите на Cache-Control, ETag, Last-Modified и итоговый статус (200 или 304).
Рекомендуемая схема: «статике — immutable, HTML — revalidation, API — по ситуации»
Если нужен простой и надёжный план:
- CSS/JS/шрифты/картинки с хэшем в имени:
public, max-age=31536000, immutable. - Картинки/файлы без хэша: небольшой
max-age(например, 1 час) и готовность к 304. - HTML:
no-cache, чтобы всегда проходила revalidation. - API: если ответы одинаковы для всех и зависят только от параметров — ETag + короткий
max-ageилиstale-while-revalidate. Если есть персонализация —privateи аккуратно с кэшированием.
Кэширование без стратегии версионирования — почти всегда компромисс. Если вы управляете URL ассетов (добавляете хэш/версию) — получаете самый предсказуемый и быстрый вариант без боли «почему не обновилось».
Мини-чеклист перед продом
- У версионированной статики длинный
max-ageиimmutable. - HTML не раздаётся с годовым кэшем.
- На приватных страницах стоит
privateи/илиno-store. - В кластере ETag (если используется) совпадает на всех нодах.
- Понимаете, где у вас revalidation (ожидаемые
304 Not Modified), а где повторных запросов вообще быть не должно.
Если проект крутится на виртуальном хостинге, а нагрузка растёт, кэширование часто даёт быстрый выигрыш без миграций. А когда упираетесь в CPU/память или нужен полный контроль над Nginx-конфигом и системными лимитами, логичный шаг — вынести проект на VDS и закрепить настройки на уровне сервера.
Если хотите — в следующем шаге можно разобрать ваш набор URL (HTML, ассеты, API) и составить точные правила Nginx по location, значения Cache-Control и схему обновления без сюрпризов.


