Москва: проверенные б/у ноутбуки с гарантией и проверкой перед оплатой
+7 906 722-94-45

Главная → Статьи → HTTP-кеширование: заголовки Cache-Control, ETag на практике

HTTP-кеширование для разработчика: Cache-Control, ETag и инвалидация

Как настроить кеш браузера и прокси: Cache-Control директивы, валидация ETag/Last-Modified, private против public, stale-стратегии и отладка кеша.

Прямой ответ

HTTP-кеширование — встроенный в протокол механизм «не качать дважды» (RFC 9111 — ссылки в шапке), и он бесплатный по производительности там, где платный кеш-сервер ещё не нужен: браузер/прокси/CDN хранят ответ и по заголовкам решают — отдать из кеша, проверить свежесть или скачать заново. Рабочий набор заголовков: Cache-Control — главная директива (max-age=31536000, immutable для версионированной статики — кеш на год без проверок; no-cache — хранить можно, но проверять свежесть каждый раз; no-store — не хранить вовсе — приватные/чувствительные ответы; private — только браузерный кеш, не прокси/CDN (персонализированные ответы); public — разрешает шаренным кешам; stale-while-revalidate=60 — отдавать устаревшее, фоном обновляя — плавность против свежести). Валидаторы — дешёвая проверка свежести: ETag (хеш/версия контента — клиент шлёт If-None-Match — совпало → 304 Not Modified без тела — экономия трафика, не запроса) и Last-Modified/If-Modified-Since (грубее — секундность и коллизии — ETag предпочтительнее). Три практических правила: (1) статика с хешем в имени (app.3f8a2c.js — контент-хеш — кеш навсегда+immutable — инвалидация сменой URL — золотой стандарт SPA-сборок — vite-сборка делает это из коробки); (2) HTML — no-cache (точка входа не кешируется надолго — иначе новый деплой «не доезжает» до пользователей — index.html всегда проверяется, ассеты под ним — вечные); (3) API — осознанно (дефолт no-store/private, no-cache для персонального; публичные справочники/ленты — public, max-age=60 + ETag — REST-практика — CDN перед API (CDN) кеширует по тем же заголовкам). Отладка — DevTools Network (Size: «memory cache»/«304» против полной загрузки) и curl -I/curl -H "If-None-Match: ..." — поведение кеша проверяется за минуту, «пользователи видят старое» — почти всегда ошибка заголовков, не мистика.

Модель кеша: свежесть, валидация, слои

Жизненный цикл кешированного ответа: запрос → кеш есть и свеж (max-age не истёк) → отдача без сети; кеш есть, но устарел → условный запрос (If-None-Match/If-Modified-Since) → 304 (тело из кеша) или 200 (новое тело); кеша нет → полный запрос. Слои кеша (каждый со своими правилами): браузер (персональный — private-ответы живут здесь), прокси/CDN (шаренный — только public — CDN-механика), промежуточные корпоративные прокси (недооценённый слой — «у пользователя в офисе своё видение» — корпоративные прокси кешируют агрессивно — Vary и no-store для критичного). Директивы тонко: max-age=0 против no-cache — оба «проверяй всегда», нюанс в хранении (практически эквивалентны для браузерного кеша); s-maxage — max-age для шаренных кешей (CDN держит час, браузер — минуту — раздельная политика — типовой паттерн лент); must-revalidate — после устаревания только проверка (не отдавать stale при недоступности источника — против stale-if-error — «лучше старое, чем ошибка» — выбор по контенту); Vary: Accept-Encoding — критично (кеш не должен отдать gzip-версию клиенту без поддержки — прокси-кеши без Vary отдают «не то» — nginx/CDN добавляют обычно сами, знать необходимо); Vary: User-Agent — почти всегда ошибка (фрагментация кеша на сотни вариантов — кеш-хитрейт в ноль — различия по UA — на клиенте, не в кеше). Хеширование контента: ETag сильный ("abc123" — побайтовое совпадение) против слабого (W/"abc123" — семантическая эквивалентность) — nginx etag on генерирует из mtime+size (быстро, но пересборка без изменения файла сбрасывает — «все ETag сменились» — для статики ок, для генерируемого — свой хеш контента точнее — nginx-кеш).

Стратегии по типам контента

Версионированная статика (js/css/шрифты/картинки с хешем в имени): Cache-Control: public, max-age=31536000, immutable — год без единой проверки (immutable — браузер не спрашивает даже при F5 — повторные визиты мгновенны; инвалидация — новый хеш в имени при сборке — старый URL жив для открытых сессий — обратная совместимость деплоя — старые и новые ассеты сосуществуют). HTML-вход (index.html/страницы): Cache-Control: no-cache (проверка каждый раз — ETag делает её дешёвой — 304 почти всегда — байты экономятся, свежесть гарантирована — деплой виден сразу). Пользовательские/персональные ответы (профиль, корзина, дашборд-API): private, no-store (или max-age=0) — кеш-отравление персональными данными через шаренный кеш — инцидент безопасности класса «пользователь увидел чужую корзину» (CSP/безопасность — кеширование в том же списке заголовочных дисциплин); авторизованные ответы с Set-Cookie — прокси-кеши обычно не кешируют автоматически, но полагаться на «обычно» — не стратегия (явный no-store). Публичные API-данные (справочники, курсы, ленты): public, max-age=60..300, s-maxage=3600 + ETag (CDN держит час, браузер минуту; 304-проверки дёшевы; инвалидация при срочности — CDN-purge по URL — CDN-операции); stale-while-revalidate=300 — устаревшее отдаётся мгновенно, обновление фоном (UX без ожидания; свежесть — «минуты назад» — приемлемо для лент/каталогов). Скачиваемое/экспорт (выгрузки): presigned-URL с no-store (ссылки одноразовые/истекающие — кеш бессмысленен и опасен); медиа с версией в пути — как статика (вечный кеш). Антипаттерны: Expires в 2026 (HTTP/1.0 наследство — Cache-Control перекрывает — не смешивать), кеш API по URL без учёта авторизации (Vary: Authorization — фрагментация; правильнее — no-store для приватного), «отключить кеш везде на время разработки» и забыть в проде (заголовки — per-окружение конфиг — конфиг-слои).

Практический сценарий: SPA грузится 6 секунд — кеш за час

Исходник: SPA (vite-сборка) за nginx: LCP 6.1 с на повторных визитах (первый — 5.8 — «кеш не работает вообще»); DevTools — все ассеты каждый раз 200 с полным телом; curl -I /assets/app.js — Cache-Control отсутствует вовсе (nginx отдаёт статику дефолтами — expires не настроен); index.html — тоже без заголовков (зато «повезло»: деплои видны сразу). Фикс по типам (nginx-конф — прокси-практика): location /assets/ { add_header Cache-Control "public, max-age=31536000, immutable"; } (всё в /assets — с контент-хешем в именах — vite гарантирует), location = /index.html { add_header Cache-Control "no-cache"; } (+etag on — 304 на повторных), API-прокси — add_header Cache-Control "private, no-store" на авторизованных маршрутах (явно — до этого прокси «молча не кешировал», теперь — контракт); публичный /api/catalog — public, max-age=120, s-maxage=1800 + FastAPI-ETag по хешу тела (API-слой) — CDN впереди подхватил s-maxage. Проверка: повторный визит — ассеты «(memory cache)» 0 байт сети, index.html — 304 (1.2 КБ вместо 240 КБ); LCP повторного визита — 0.9 с (с 6.1); деплой нового билда — пользователь видит сразу (index no-cache → новые хеши ассетов — мгновенная «инвалидация» без purge); нагрузочная проверка CDN-хитрейта — каталог: hit ratio 94% через час (s-maxage работает — нагрузка на API −80%); проверка приватности — curl с двумя разными сессиями через шаренный кеш-тест — персональные ответы не кешируются (no-store подтверждён — безопасность — «чужая корзина» исключена). Бонус-находка: шрифты с Google-хоста кешировались браузером, но блокировали рендер — self-host + preload (изображения/ресурсы — та же дисциплина ресурсов) — ещё −400 мс LCP. Итог: час настройки заголовков — повторные визиты ×6 быстрее, CDN разгружает API, деплои мгновенны — бесплатный уровень производительности, который предшествует любой тяжёлой оптимизации (web vitals — следующий слой).

FAQ

no-cache против no-store — в чём разница на практике? no-cache — «храни, но проверяй каждый раз» (условный запрос → 304 экономит тело — повторные визиты быстрые на плохой сети; запрос всё равно уходит — свежесть гарантирована); no-store — «не храни ничего» (каждый визит — полная загрузка — для приватного/чувствительного/меняющегося каждый раз); «no-cache = не кешировать» — распространённое заблуждение имени (не кешировать — именно no-store); HTML-входы — no-cache, персональные данные — no-store, версия-статика — max-age большой.

Как инвалидировать кеш, если URL без хеша (легаси)? Варианты по убыванию чистоты: версионировать URL (query ?v=2 или путь /v2/ — смена URL = новый кеш — работает везде, включая корпоративные прокси), CDN-purge по URL/префиксу (минуты на распространение, платно на объёмах — CDN-операции), сократить max-age (60 с — «инвалидация временем» — компромисс трафика и свежести), cache-busting на клиенте (fetch с случайным параметром — только для API/XHR, не для тегов); «поставить no-store на всё после инцидента» — лечение симптома с потерей всей пользы кеша — хеш в имени решает системно.

Нужен ли ETag для API-ответов? Для тяжёлых публичных GET — да (304 экономит сериализацию+трафик: ETag по хешу тела — if response_hash == if_none_match: 304 — FastAPI-middleware на 20 строк; клиентские polling-сценарии выигрывают заметно); для персональных/платных в вычислении — особенно (проверка дешевле генерации); для лёгких/потоковых/POST — нет (накладные на хеш без выигрыша); нюанс: ETag должен быть детерминирован (порядок ключей JSON/пробелы — хеш считается от канонической сериализации — сериализация — orjson стабильнее в этом смысле).

Почему кеш «работает локально, но не за CDN/прокси»? Частые причины: public отсутствует (шаренные кеши уважают private/его отсутствие — CDN не кеширует персональное по спецификации), Set-Cookie в ответе (многие CDN по умолчанию не кешируют ответы с куками — убрать куки с публичных маршрутов), Vary-фрагментация (каждый вариант Vary — свой кеш-ключ — Accept-Encoding ок, User-Agent — смерть хитрейта), Authorization-заголовок на публичном маршруте (Vary/пропуск — CDN считают ключом), TTL-политики CDN поверх заголовков (некоторые CDN игнорируют s-maxage при своём конфиге — читать доки провайдера); отладка — curl -I через CDN и напрямую (сравнение заголовков — X-Cache/HIT-MISS провайдера — картина за минуту).

Источники: RFC 9111 (HTTP Caching), MDN Caching, документация nginx headers — ссылки в шапке.