Скажем "нет" слепому копированию заголовков кеширования.
Введение
Как браузер принимает решение о кешировании? Что такое условный HTTP-запрос? Как возникает 304 Not Modified? Как устроен принудительный запрос ресурса, минуя кеш? Если эти вопросы для вас актуальны, прошу на огонёк.
В живом эксперименте разберем фундамент HTTP-кеширования: заголовки Date, ETag, Last-Modified, If-None-Match, If-Modified-Since, немного затронем Cache-Control (но в целом Cache-Control будет предметом отдельной статьи). Никакой магии, только реальные наблюдения и сопоставление с требованиями RFС-спецификаций.
Изложение намеренно подробное: статья адресована всем, и кто только начинает путь в веб-разработке, и тем, кто просто хочет закрыть отдельные пробелы в понимании механизма кеширования.
Для демонстрации со стороны сервера используется популярный Python-стек (сервер uvicorn + фреймворк Starlette), местами заглянем в их исходники.
Что такое кеш в рамках HTTP-протокола
HTTP-кеш — это локальное хранилище сообщений ответов и подсистема, управляющая сохранением, извлечением и удалением сообщений в нём. Кеш сохраняет ответы на запросы, чтобы сократить время их доставки клиенту и уменьшить нагрузку на сеть.
Правила кеширования определены специальным документом, RFC 9111 "HTTP Caching". Этих правил должны придерживаться и клиенты (например, браузеры), и серверы, и посредники (прокси-серверы). Также для обеспечения механизма кеширования используются заголовки из RFC 9110 "HTTP Semantics".
RFC 9111 выделяет 2 типа кешей.
Общий кеш (shared cache) — это кеш, который сохраняет ответы для повторного использования более чем одним пользователем.
Приватный кеш (private cache) — это личный кеш конкретного пользовател.
Проще говоря:
кеш в браузере — это приватный кеш;
кеш на узлах-посредниках прохождения запроса (прокси-серверах, например, Nginx) — это общий кеш.
Работу серверного кеша (Redis, Memcached и т.д.) спецификация не регламентирует.
Тестовый стенд
Как организовано кеширование по HTTP-протоколу я покажу на примере запросов между:
программой-клиентом — браузер Firefox 148.0.2
программой-сервером — Python-сервер
uvicorn(версия 0.41.0) + веб-фреймворкStarlette(версия 0.52.1).
# main.py import uvicorn from starlette.applications import Starlette from starlette.routing import Mount from starlette.staticfiles import StaticFiles app = Starlette(routes=[ Mount("/static", StaticFiles(directory="static"), name="static"), ]) if __name__ == "__main__": uvicorn.run(app, host="127.0.0.1", port=8100)
Сценарий типичный:
клиент делает запрос методом GET к URI ресурса
http://127.0.0.1:8100/static/style.css, чтобы получить CSS-файл (он же "ресурс") со стилями веб-страниц (т.н. "статический контент", "статика")сервер, получив запрос, считывает содержимое файла
style.cssс диска, и отправляет содержимое клиенту (или не отправляет, если клиент ранее закешировал ответ, механику этого процесса мы и будем подробно изучать далее).
Структура проекта
$ tree . ├── main.py └── static └── style.css
Первоначальное содержимое файла style.css.
body { background-color: green; }
Всего будет сделано 5 запросов к одному и тому же URI.
Подготовительные действия
На стороне сервера
Для тех, кто захочет повторить: убедиться, что структура проекта соответствует ожидаемой; убедиться, что программа-сервер работает и была запущена из директории проекта.
Зафиксировать метаданные файла-ресурса. Я работаю в Linux-окружении, поэтому воспользуюсь утилитой stat.

Запомним 2 вещи:
файл занимает 38 байт;
содержимое файла последние раз менялось (Modify) в 12 ч 29 мин по Москве (GMT +3). Если файл был создан и его содержимое ни разу не менялось, то Modify отражает и время создания файла. Это как раз наш случай.
На стороне клиента
Открыть браузер. Открыть (и больше не закрывать) панель разработчика (DevTools, клавиша F12). Выбрать вкладку сеть (Network). Убедиться, что личный кеш браузера не отключен.

Первый запрос (14 ч 45 мин): получаем ресурс от сервера
Для простоты буду делать запросы прямо из адресной строки браузера.

Почему я указал точное время запроса в х��де наших тестов? Наберитесь терпения, эта информация понадобится во втором запросе.
Заголовки запроса
Браузер сопроводил свой GET-запрос такими заголовками:

Привожу просто для полноты информации, конкретно в части понимания механизма кеширования ни один из них нам не понадобится.
Ответ сервера
В девтулз браузера видим:

В логах сервера
INFO: 127.0.0.1:57672 - "GET /static/style.css HTTP/1.1" 200 OK
Сервер ответил статусом 200 OK, что по соглашению означает успешность запроса. Конкретно для GET-метода этот статус означает, что сервер отдал клиенту содержимое ("представление") запрашиваемого ресурса (RFC 9110, секция 15.3.1).
Также обратите внимание на "размер 38 б", это те самые 38 байт, которые занимает файл style.css.
Заголовки ответа сервера

В контексте кеширования обратите внимание на 3 заголовка, которые указал в ответе сервер:
заголовок Date
заголовок ETag
заголовок Last-Modified
Заголовок Date
Определен в RFC 9110, секция 6.6.1.
Заголовок содержит дату и время, когда сервер сформировал ответ. Формат всегда в GMT, часовые пояса не используются. В нашем примере:
date: Sun, 15 Mar 2026 11:45:19 GMT
Это 14:45:19 по Москве, момент когда сервер обработал запрос и отправил ответ. В нашем случае в 14 ч 45 мин сервер обратился к файлу style.css, прочитал его содержимое, упаковал в тело ответа и отправил клиенту.
Проверим. Заглянем в метаданные файла style.css после сделанного запроса.

Видим, что время в Access (доступ к файлу) поменялось. Было 12 ч 29 мин 46 сек, стало 14 ч 45 мин 20 сек. Подтверждается то, что "сервер сходил и прочитал запрашиваемый файл".
Расхождение в 1 секунду между Date: 11:45:19 GMT (14:45:19 МСК) и Access: 14:45:20 нормально, это просто разница между моментом, когда сервер начал формировать ответ и проставил Date, и моментом когда файловая система зафиксировала факт чтения файла.
Заголовок ETag
Определён в RFC 9110, секция 8.8.3.
Заголовок содержит уникальный идентификатор конкретной версии ресурса, своего рода "отпечаток".
Значение заголовка непрозрачно для клиента, он не знает и не должен знать как оно вычислено. Его задача просто сохранить это значение и предъявить серверу при следующем запросе.
Бывает двух видов: сильный (по умолчанию) и слабый (с префиксом W/). Сильный гарантирует побайтовую идентичность, слабый только семантическую эквивалентность.
В нашем случае значение ETag вот такая строка
5db20299546bf7c017020e4c9e2a318b
Она была вычислена в Python-коде фреймворка Starlette путем хеширования времени модификации файла и его размера.
etag_base = str(stat.st_mtime) + "-" + str(stat.st_size) etag = hashlib.md5(etag_base.encode()).hexdigest()
В нашем случае это дало5db20299546bf7c017020e4c9e2a318b. Разумеется, если будете повторять, то значение будет другим, т.к. время модификации будет отличаться.
Заголовок Last-Modified
Определён в RFC 9110, секция 8.8.2.
Заголовок содержит дату и время последней модификации содержимого ресурса.
В нашем случае это то самое значение Modify из метаданных файла, переведённое в GMT (то есть минус 3 часа от московского времени):
last-modified: Sun, 15 Mar 2026 09:29:46 GMT
Это более старый механизм идентификации версии ресурса, появился ещё в HTTP версии 1.0, тогда как ETag добавили в версии 1.1. Главный недостаток Last-Modified в точности до одной секунды: если ресурс изменился дважды за секунду, оба изменения получат одинаковое значение и различить их будет невозможно.
ETag считается более точным валидатором и всегда имеет приоритет. Last-Modified остаётся для обратной совместимости с клиентами, которые ETag не поддерживают.
Из этого нужно просто вынести 2 вещи:
заголовки
ETagиLast-Modifiedслужат одной и той же цели, просто механизмы вычисления их значений разные;если говорить о связке "браузерный клиент - сервер", то все современные браузеры знают ETag и именно по его значению судят о версии ресурса.
Кто сгенерировал значения для всех этих заголовков, ведь в коде сервера их нет
Явный код нашего сервера действительно не содержит ничего, связанного с работой с заголовками. Эта часть скрыта в импортированных библиотеках uvicorn и Starlette. uvicorn сгенерировал Date, остальные 2 работа Starlette.
Важно помнить, что HTTP-спецификация — это лишь набор правил и соглашений. Их реализация целиком лежит на конкретном программном обеспечении. Разные серверы могут реализовывать одни и те же требования по-разному, а часть требований и вовсе не реализовывать.
Поэтому всегда нужно учитывать две вещи: что требует спецификация и как именно это реализовано в конкретном сервере или фреймворке. Иначе можно рассчитывать на поведение, которого на самом деле нет, или, наоборот, писать код который уже реализован под капотом.
Что мы узнали после первого запроса
Когда сервер отдает клиенту (браузеру) содержимое файла, то в заголовках ответа он, среди прочего сообщает:
дату формиров��ния ответа (заголовок
Date)версию ресурса в двух форматах (заголовок
ETagи заголовокLast-Modified). Если естьETagи клиент понимает его, то он всегда в приоритете.
Эту информацию клиент запоминает, а что с ней будет делать, мы узнаем далее.
Второй запрос (14 ч 50 мин): первое знакомство с приватным кешем
Нажимаю Enter в адресной строке браузера и отправляю второй запрос к http://127.0.0.1:8100/static/style.css.
На старте зафиксируем очень важную информацию (в чем ее важность расскажу чуть ниже):
первый запрос был сделан в 14 ч 45 мин
второй запрос сделан в 14 ч 50 мин, т.е. спустя 5 минут после первого
на момент первого запроса между временем запроса и временем последней модификации файла-ресурса прошло 135 минут (разница между 14 ч 45 мин 19 сек и 12 ч 29 мин 46 сек по мск)
Смотрим в девтулз браузера.

Запрос есть, статус ответа 200 OК есть, но в колонке передано указано "кешировано".
Смотрим в логи сервера, там тишина.
"Кешировано" + статус 200 означает, что:
после предыдущего запроса браузер сохранил содержимое
style.cssв своем кеше;при новом запросе браузер не стал делать запрос к серверу;
отдал пользователю ранее сохраненное содержимое
style.cssв девтулз продублировал заголовки запроса и ответа из предыдущего запроса.
Вопрос: почему браузер даже не попытался спросить у сервера, актуально ли по-прежнему содержимое файла?
Здесь сработала "интуиция браузера" или, если придерживаться буквы спецификации, "расчет эвристической свежести ресурса" (RFC 9111, секция 4.2.2).
Эвристическая свежесть, или Почему браузер предположил, что ресурс не изменился
Спецификация разрешает браузеру самостоятельно рассчитать время жизни ресурса, если сервер не указал явных инструкций на этот счет через заголовки Cache-Control или Expires (эти заголовки мы рассмотрим в другой статье). Это и называется "эвристической свежестью" (heuristic freshness).
Конкретный алгоритм ее вычисления спецификация не дает, но даёт рекомендацию: если в ответе есть заголовок Last-Modified, то можно использовать долю от интервала между значениями Date и Last-Modified, например, 10% (разумеется, конкретный клиент волен выбрать другую величину).
В нашем случае разница между значениями в заголовках Date (11:45:19 GMT) и Last-Modified (09:29:46 GMT) в предыдущем ответе сервера составила те самые 135 минут, о которых я писал в начале текущего раздела. 10% от 135 минут = 13,5 минут.
Следовательно:
в течение 13,5 минут (если предположить, что Firefox установил те же 10%, которые упоминает спецификация как "типичный кейс") после получения файла-ресурса браузер считает его свежим;
если в течение этого времени пользователь снова запросит этот файл, браузер достанет его версию из своего кеша.
Что мы узнали после второго запроса
Выводы:
если в браузере не отключен кеш, он сохраняет полученный файл вместе с метаданными ответа сервера, включая идентификатор ресурса, дату ответа и дату последней модификации ресурса;
если сервер ничего не сказал о времени жизни ресурса (не дал инструкций кеширования), браузер сам рассчитывать время жизни файла ("эвристическую свежесть"). В этом расчете он может опираться на данные из заголовков
DateиLast-Modified, которые прислал сервер, когда впервые передал закешированный ресурс;эвристическая свежесть (при использовании примерного алгоритма из RFC 9111) тем больше, чем больше разница между временем ответа сервера, в котором пришел этот файл, и временем последней модификации файла
если вы будете повторять мой тест, учтите, что второй запрос может не дать тот же результат, что сейчас, если период эвристической свежести будет очень маленьким (повторный запрос просто не успеет уложиться в окно).
Третий запрос (15 ч 09 мин): знакомимся с механизмом валидации кеша
Эвристическая свежесть ресурса в текущем тесте составила 13,5 минут с момента первого запроса (14 ч 45 мин).
На момент третьего запроса прошло уже 24 минуты. "Свежесть" исчезла (повторю: мы предполагаем, что Firefox использует тот же примерный алгоритм, что описан в RFC 9111). Как будет действовать браузер? Смотрим!

Видим снова "кешировано", но (важно!) новый статус ответа 304.
Смотрим логи сервера
INFO: 127.0.0.1:50719 - "GET /static/style.css HTTP/1.1" 304 Not Modified
Ага, запрос в этот раз ушел на сервер, сервер получил и ответил.
Смотрим метаданные файла-ресурса (напомню, сейчас 15 ч 09 мин).

Последний раз содержимое файла читалось в 14 ч 45 мин (т.е. в момент первого запроса нашего теста). Прим.: привожу изменение Access как косвенное подтверждение, в зависимости от настроек файловой системы конкретной машины этот показатель может не обновляться даже при реальном чтении файла.
Исходя из собранных данных получается:
запрос к серверу ушел (логи это подтверждают)
сервер не читал содержимое файла ресурса (статистика файла это подтверждает)
сервер вернул ответ 304 Not Modified
браузер получив этот ответ снова отдал пользователю закешированный на своей стороне файл.
Интуитивно уже ясно, почему так произошло: сервер не увидел изменений в метаданных файла-ресурса, сообщил об этом браузеру, браузер понял, что та версия ресурса, которую он закешировал, свежая и со спокойной совестью снова отдал ее пользователю. Это называется валидацией кеша, а запрос для такой валидации называется условным запросом.
Теперь подробнее как это устроено.
Валидация кеша, этап 1
Посмотрим на заголовки запроса, которые в этот раз ушли от браузера, и увидим 2 новых.

Заголовки If-Modified-Since и If-None-Match используются клиентом для проверки того, а не изменилась ли версия ресурса по сравнению с той, что была закеширована клиентом.
Заголовок запроса If-Modified-Since
Определён в RFC 9110, секция 13.1.3.
Браузер берёт значение из заголовка Last-Modified предыдущего ответа сервера и отправляет его обратно уже в своем запросе:
If-Modified-Since: Sun, 15 Mar 2026 09:29:46 GMT
Заголовок запроса If-None-Match
Определён в RFC 9110, секция 13.1.2.
Браузер берёт значение из заголовка ETag предыдущего ответа сервера и отправляет его обратно уже в своем запросе:
If-None-Match: "5db20299546bf7c017020e4c9e2a318b"
Смысл вопроса к серверу: "совпадает ли текущая версия ресурса с этим идентификатором?".
Согласно RFC 9110, секция 13.1.3, если в запросе присутствуют оба заголовка, то:
If-None-Matchимеет приоритетIf-Modified-Sinceигнорируется.
ETag считается более точным валидатором, поэтому современные браузеры отправляют оба заголовка одновременно исключительно ради совместимости со старыми серверами, которые ETag не поддерживают.
В нашем случае If-Modified-Since не актуален для сервера, т.к. Starlette умеет работать с Etag.
Значит, получив If-None-Match, Starlette должен заново взять метаданные файла ресурса (открывать его не нужно для этого), вычислить ETag и сравнить результат с ETag, который пришел в If-None-Match.
# starlette/staticfiles.py def is_not_modified(self, response_headers: Headers, request_headers: Headers) -> bool: if if_none_match := request_headers.get("if-none-match"): etag = response_headers["etag"] return etag in [tag.strip(" W/") for tag in if_none_match.split(",")] try: if_modified_since = parsedate(request_headers["if-modified-since"]) last_modified = parsedate(response_headers["last-modified"]) if if_modified_since is not None and last_modified is not None and if_modified_since >= last_modified: return True except KeyError: pass return False
Логика точно такая, как мы и описали: сначала проверяет If-None-Match, если есть, сравнивает ETag и возвращает результат. До If-Modified-Since доходит только если If-None-Match отсутствует.
Результат валидации: 304 Not Modified против 200 OK
Сервер получил запрос с If-None-Match, вычислил текущий ETag файла, сравнил с пришедшим значением.
Если файл не изменился, сервер возвращает специальный статус ответа 304 Not Modified (RFC 9110, секция 15.4.5). Он означает: "клиент/прокси, твоя закешированная версия файла актуальна, используй её". Тело ответа не передаётся, только заголовки. Браузер отдаёт пользователю версию из своего кеша. Трафик экономится.
Если файл изменился, сервер возвращает обычный 200 OК, в теле ответа содержимое с новой версии файла и новыми значениями в заголовках ETag и Last-Modified. Браузер отдает актуальный файл пользователю и заодно обновляет свой кеш.
В нашем случае ETag не изменился, поэтому ответ сервера был лаконичен: он просто продублировал не изменившийся ETag.

Что мы узнали из третьего запроса
Мы узнали, что:
если срок свежести ресурса истек, браузер не убирает его из кеша, а пытается провалидировать (мало ли, ничего не изменилось?);
валидация заключается в отправке серверу условного запроса с заголовками
If-None-MatchиIf-Modified-Since, в них браузер записывает соответственно значения из заголовковETagиLast-Modified, которые ранее ему присылал серверсервер проверяет актуальность ресурса по метаданным файла, не читая его содержимое. Если ресурс не изменился, то возвращает
304 Not Modifiedбез тела. Браузер получает304и отдаёт пользователю версию из своего кеша. Трафик экономится.
Четвертый запрос: что будет, если ресурс изменился
Собственно говоря, ответ на этот вопрос вы уже знаете, если внимательно прочитали предыдущий раздел. Сервер обнаружит несовпадение присланного ETag с текущим и вернет новое содержимое ресурса вместе с кодом 200 OК.
Но не будем верить на слово, проверим.
Изменим любым образом содержимое style.css. Я поменял в тексте файла green на red. Смотрим метаданные ресурса.

Теперь файл весит 36 байт (вместо 38), изменилась и дата модификации (теперь 15 ч 38 мин вместо 12 ч 29 мин).
Делаем запрос из браузера.

Ожидаемо: ответ 200 ОК, пришел файл размером 36 байт.
В логах сервера
INFO: 127.0.0.1:61427 - "GET /static/style.css HTTP/1.1" 200 OK
Смотрим снова метаданные файла.

Видим, что время доступа изменилось с 15 ч 38 мин на 15 ч 41 мин, значит, сервер, прочитал содержимое файла, а не ограничился только проверкой метаданных.
Почему?
Смотрим заголовки запроса браузера:

Браузер в заголовке If-None-Match передал значение ETag ресурса 5db20299546bf7c017020e4c9e2a318b.
Сервер отвечает: новый ETag валидируемого ресурса 8c8ad6850c615bd49a5ee027a31ffb66.

Раз текущий ETag отличается от того, что прислал браузер, сервер принял решение заново прочитать файл-ресурс и полностью его отдать клиенту со статусом 200 ОК.
Что мы узнали из четвертого запроса
Если по результатам условного запроса выяснилось, что ресурс изменился (присланный клиентом и актуальный ETag не совпадают), сервер читает файл заново и возвращает 200 OK с новым содержимым, новым ETag и новым Last-Modified. Браузер обновляет свой кеш и отдаёт пользователю актуальную версию ресурса.
Пятый запрос: что происходит при принудительной перезагрузке страницы в браузере
Наверняка вы не раз слышали совет "попробуйте сбросить кеш" или "нажмите Ctrl+F5". Разберёмся, что именно происходит при этом на уровне протокола.
Нажимаю в окне браузера ctrl + F5.
Результат в девтулз.

Несмотря на то, что ресурс на сервере не изменился, браузер не стал отправлять условный запрос (на который бы гарантированно получил 304), а каким-то образом заставил сервер напрячься и заново отправить весь файл. Как?
Смотрим заголовки запроса.

Обратите внимание:
появился новый заголовок
Cache-Controlсо значениемno-cacheв запросе нет If-заголовков, значит, запрос не условный (т.е. запрос не на проверку свежести кеша)
Заголовок Cache-Control
Определён в RFC 9111, секция 5.2.
Этот заголовок может присутствовать как в запросе, так и в ответе, и является основным инструментом явного управления кешированием в HTTP. Это большая отдельная тема, которую я разберу в отдельной статье.
Сейчас нас интересует только одна директива, no-cache, в заголовке клиентского запроса (RFC 9111, секция 5.2.1.4)
Cache-Control: no-cache по букве спецификации означает "клиент предпочитает не использовать закешированный ресурс без его успешной валидации на сервере". В нашем тесте мы видим, что браузер в реальности поступает более радикально. Он не валидирует ресурс (и не просит это сделать возможных прокси на пути), а просто отправляет обычный (без If-заголовков) запрос. Сервер ничего не остается как отдать ресурс заново.
Что мы узнали из пятого запроса
Ctrl+F5 — способ пользователя принудительно получить свежую версию ресурса. Браузер добавляет в запрос Cache-Control: no-cache и не отправляет If-заголовки. Запрос перестаёт быть условным, сервер не получает шанса ответить 304 и вынужден отдать ресурс полностью.
Заключение
В этой статье мы разобрали базовый механизм HTTP-кеширования на примере самой простой схемы: браузер и сервер без посредников.
Мы выяснили:
сервер, отдавая ресурс (содержимое запрошенного файла), автоматически снабжает ответ метаданными, включая заголовки
Date,ETag,Last-Modified. Данных из этих заголовков браузер использует для управления своим кешем;если сервер не дал явных инструкций по кешированию, браузер не бездействует, он самостоятельно рассчитывает эвристический срок свежести ресурса, опираясь на метаданные ресурса;
если срок свежести истек, то при новом запросе клиентом того же ресурса браузер не выбрасывает кеш, а валидирует его: идёт к серверу с условным запросом (заголовки
If-None-MatchиIf-Modified-Since) и получает либо304 Not Modifiedбез тела, либо свежую версию файла с200 OK;Ctrl+F5 для браузера означает "запросить ресурс заново без условий", что на уровне протокола выражается в добавлении
Cache-Control: no-cacheи отсутствии If-заголовков.
В нашем эксперименте всё это работало без единой строчки кода, связанного с кешированием, только за счёт поведения браузера и кода внутри Python-фреймворка на стороне сервера.
В следующей статье разберём как разработчик может взять управление кешированием в свои руки через заголовок Cache-Control и зачем это вообще нужно, если браузер и так неплохо справляется сам.
