Выберите продукт

HTTP caching headers: Cache-Control, ETag и Last-Modified без боли

Разбираем, как браузер и CDN кэшируют ответы: Cache-Control, ETag, Last-Modified, revalidation и 304 Not Modified. Даю рабочие профили для статики, HTML и API, типовые грабли и готовые примеры конфигов Nginx (expires, add_header, etag).
HTTP caching headers: Cache-Control, ETag и Last-Modified без боли

Зачем вообще разбираться в 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.

Схема: свежесть (freshness) и revalidation в HTTP-кэше, переходы к 304 Not Modified

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. Это нормально, если вы контролируете итоговые заголовки.

Виртуальный хостинг FastFox
Виртуальный хостинг для сайтов
Универсальное решение для создания и размещения сайтов любой сложности в Интернете от 95₽ / мес

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

Сценарий выглядит так:

  1. Клиент получил ответ, но срок свежести истёк (или max-age=0).
  2. Клиент отправляет условный запрос: If-None-Match и/или If-Modified-Since.
  3. Сервер сравнивает валидатор и отвечает 304 или 200.
  • 304 Not Modified — тело не отправляется, клиент использует кэш.
  • 200 OK — если ресурс изменился, возвращается новое тело.

Важно: 304 всё равно стоит CPU/IO на сервере (проверка файла/метаданных, логика приложения, логи). Поэтому для статики цель обычно не «много 304», а «мало запросов вообще» — через длительную свежесть и versioning.

Пример проверки кэш-заголовков через curl: сравнение ответов 200 OK и 304 Not Modified

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 не делает кэш «лучше», оно лишь убирает один механизм валидации.

FastFox VDS
Облачный VDS-сервер в России
Аренда виртуальных серверов с моментальным развертыванием инфраструктуры от 195₽ / мес

Как не убить кэш авторизацией, куками и 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 и схему обновления без сюрпризов.

Поделиться статьей

Вам будет интересно

Debian/Ubuntu: конфликт systemd-resolved DNSStubListener на 127.0.0.53 с dnsmasq, Unbound и BIND OpenAI Статья написана AI (GPT 5)

Debian/Ubuntu: конфликт systemd-resolved DNSStubListener на 127.0.0.53 с dnsmasq, Unbound и BIND

Если локальный DNS в Debian или Ubuntu не стартует с ошибкой address already in use, причина часто в systemd-resolved и DNSStubLis ...
Debian/Ubuntu: как исправить NFS mount.nfs: access denied by server while mounting OpenAI Статья написана AI (GPT 5)

Debian/Ubuntu: как исправить NFS mount.nfs: access denied by server while mounting

Ошибка mount.nfs: access denied by server while mounting в Debian и Ubuntu обычно указывает на проблему на стороне NFS-сервера: не ...
Debian/Ubuntu: как устранить конфликт systemd-resolved DNSStubListener с BIND9, dnsmasq и AdGuard Home OpenAI Статья написана AI (GPT 5)

Debian/Ubuntu: как устранить конфликт systemd-resolved DNSStubListener с BIND9, dnsmasq и AdGuard Home

Если в Debian или Ubuntu DNS-сервер не стартует из-за ошибки port 53 busy, часто причина в systemd-resolved с локальным слушателем ...