Кеширование в браузере

Самый быстрый запрос — тот, которого не было. Кеш — единственная оптимизация, которая делает загрузку ресурса бесплатной, и настраивается она за один вечер.

HTTP-кеш браузера — это хранилище ответов сервера (в памяти и на диске), из которого браузер берёт файл повторно: либо вообще молча, либо спросив у сервера одним дешёвым вопросом «а он изменился?».

Зачем это нужно

Посмотрите на любую метрику из предыдущих разделов — LCP, TBT, вес бандла. Всё это мы оптимизируем, борясь за десятки килобайт и сотни миллисекунд. А кеш работает грубее и сильнее: он убирает запрос целиком. Ноль байтов по сети, ноль ожидания DNS, TCP и TLS, ноль нагрузки на ваш сервер.

Порядок величин такой. На мобильном 4G один сетевой круг (RTT) — это 50–150 мс. Чтобы получить первый байт файла со свежего соединения, нужно 3–4 таких круга: DNS, TCP-рукопожатие, TLS-рукопожатие, сам запрос. То есть даже пустой файл в 1 КБ «стоит» полсекунды. Файл из дискового кеша отдаётся за 1–5 мс, а из памяти — почти мгновенно. Разница не в процентах, а в порядках.

Практический эффект: правильно настроенный кеш превращает повторный визит на сайт в загрузку одного HTML-документа. Всё остальное — JS, CSS, шрифты, картинки, иконки — уже лежит у пользователя. Первый визит вы этим не ускорите, но второй, третий и все следующие (а это обычно большинство ваших сессий) станут почти мгновенными. И всё это — за счёт нескольких строк в конфиге, без единой строчки кода.

Cache-Control: заголовок, который решает всё

Кешированием управляет сервер, отправляя вместе с файлом заголовок Cache-Control. Браузер ему подчиняется. Выглядит это так:

HTTP/1.1 200 OK
Content-Type: application/javascript
Content-Length: 121840
Cache-Control: public, max-age=31536000, immutable
ETag: "a3f19c-7b2e"

Директив много, но на практике вам нужны шесть:

ДирективаЧто означает
max-age=NОтвет считается свежим N секунд. Всё это время браузер берёт его из кеша вообще без запроса к серверу.
no-cacheКешировать можно, но перед каждым использованием надо переспросить сервер («не изменилось?»). Это не запрет кеша.
no-storeВот это — настоящий запрет: не писать ответ ни на диск, ни в память. Для страниц с приватными данными.
publicОтвет можно кешировать не только браузеру, но и общим кешам — CDN, корпоративному прокси.
privateТолько личный кеш браузера. CDN такой ответ хранить не должен.
immutableОбещание: пока ответ свежий, он точно не изменится. Браузер не будет перепроверять его даже при нажатии «Обновить».

Запомните главное различие, на котором спотыкаются почти все: no-cache и no-store — совершенно разные вещи. no-cache означает «кешируй, но валидируй». no-store — «не смей сохранять». Имена, к сожалению, выбраны неудачно.

ETag и условные запросы

Когда время жизни max-age истекло, браузер не скачивает файл заново вслепую. Он идёт на сервер с условным запросом: «дай мне файл, но только если он отличается от того, что у меня уже есть». Идентификатором версии служит ETag — короткая строка-отпечаток содержимого (обычно хеш), которую сервер прислал в прошлый раз. Есть и более старый механизм на дате — Last-Modified / If-Modified-Since, он менее точен (гранулярность в секунду) и работает так же по смыслу.

// Запрос браузера при повторном визите
GET /assets/logo.svg HTTP/1.1
If-None-Match: "a3f19c-7b2e"

// Ответ сервера, если файл не менялся
HTTP/1.1 304 Not Modified
ETag: "a3f19c-7b2e"
Cache-Control: public, max-age=600

Ответ 304 Not Modified не содержит тела: сервер экономит трафик и говорит «бери свою копию». Это уже неплохо — вместо 40 КБ едет пара сотен байт заголовков. Но круг по сети всё равно совершается, а он и есть самое дорогое. Условный запрос — это компромисс, а не победа. Победа — когда браузер не спрашивает вообще.

Хеш в имени файла и вечный кеш

Как разрешить браузеру не спрашивать сервер год, но при этом уметь выкатить новую версию сегодня? Ответ придуман давно и работает безотказно: сделать имя файла зависимым от его содержимого. Любой современный сборщик (Vite, webpack, Rollup, esbuild, Next.js) умеет это из коробки:

dist/assets/index-4f2a9c1e.js
dist/assets/vendor-9b1c02af.js
dist/assets/style-7ac31140.css
dist/assets/Inter-var-c3d90b21.woff2

Здесь 4f2a9c1e — хеш от содержимого файла. Поменялась хоть одна строчка кода — поменялся хеш, а значит, и URL. Новый URL браузер никогда не видел, старый кеш ему не помеха, он честно скачает новый файл. А старый файл по старому URL действительно неизменен — он не может измениться по определению. Значит, ему можно смело выдать max-age на год и immutable.

Отсюда рождается простое правило всего двух политик, которое покрывает 95% сайтов:

ЧтоCache-ControlПочему
Статика с хешем в имени (JS, CSS, шрифты, картинки сборки)public, max-age=31536000, immutableСодержимое по этому URL никогда не изменится. Кешируем навечно (год — максимум, который имеет смысл).
HTML-документ (index.html, страницы SSR)no-cacheИменно он содержит ссылки на новые хеши. Он обязан быть свежим, иначе деплой не доедет до пользователя.

В nginx это буквально десять строк:

location /assets/ {
    # Файлы с хешем в имени — вечный кеш, без перепроверок
    add_header Cache-Control "public, max-age=31536000, immutable";
    try_files $uri =404;
}

location = /index.html {
    # HTML всегда валидируем: он раздаёт ссылки на новые хеши
    add_header Cache-Control "no-cache";
}

location / {
    try_files $uri /index.html;
}

Проверить результат можно прямо из терминала, не открывая DevTools:

curl -sI https://example.com/assets/index-4f2a9c1e.js | grep -i cache-control
# cache-control: public, max-age=31536000, immutable

curl -sI https://example.com/ | grep -i cache-control
# cache-control: no-cache

Как это работает

Когда браузеру нужен ресурс, он проходит по цепочке сверху вниз и останавливается на первом успехе:

  1. Memory cache. Ресурс уже загружался в этой же вкладке — берётся из оперативной памяти за доли миллисекунды. Живёт до закрытия вкладки, заголовками не управляется.
  2. Disk cache, ответ свежий. С момента загрузки прошло меньше max-age секунд — файл читается с диска, сеть не трогается вообще. В DevTools вы увидите статус 200 и размер (disk cache).
  3. Disk cache, ответ протух. max-age истёк (или стоит no-cache) — браузер отправляет условный запрос с If-None-Match. Если сервер ответил 304, копия из кеша объявляется снова свежей и используется. Тело не качается.
  4. Сеть. Кеша нет, или сервер ответил 200 с новым телом — полноценная загрузка.

Директива immutable вмешивается в третий шаг: она говорит браузеру не отправлять условный запрос даже тогда, когда пользователь нажал «Обновить страницу» (F5). Без неё Chrome и Safari при перезагрузке ревалидируют всю статику, и вы получаете десяток бесполезных запросов с ответом 304 — по кругу сети каждый.

Отдельно стоит знать про stale-while-revalidate=N. Эта директива говорит: «после протухания ещё N секунд можешь отдавать старую копию мгновенно, а свежую подтянуть фоном». Идеально для данных, где допустима лёгкая несвежесть — аватарки, ленты, конфиги: Cache-Control: max-age=60, stale-while-revalidate=600.

Частые ошибки

  • Длинный max-age на HTML. Классика, которая ломает деплой: пользователь неделю видит старую версию сайта, потому что его index.html закеширован на 7 дней и ссылается на старые хеши. Откатить это нельзя — заголовок уже у него в кеше. HTML — только no-cache.
  • Путаница no-cache и no-store. Ставят no-cache на страницу личного кабинета, думая, что запретили кеширование. Ответ при этом честно лежит на диске и достаётся оттуда после 304. Для приватных данных нужен no-store.
  • Cache-busting через query-параметр вместо хеша в имени. Приём /app.js?v=17 работает в браузере, но некоторые прокси и старые CDN игнорируют query-строку при кешировании и отдают не тот файл. Хеш в имени файла надёжнее.
  • Забыли про immutable. Кеш вроде настроен на год, но каждое обновление страницы всё равно даёт пачку запросов с 304. На мобильной сети это лишние полсекунды на ровном месте.
  • ETag на кластере из нескольких серверов. Nginx и Apache по умолчанию генерируют ETag из inode и времени файла, а они на разных машинах разные. Один и тот же файл получает разные ETag — ревалидация никогда не срабатывает, каждый раз качается тело. Лечится хешем от содержимого или отключением ETag (etag off), если Last-Modified хватает.
  • Отладка с включённым «Disable cache». В DevTools этот флажок стоит у большинства разработчиков постоянно, и они месяцами не видят, что кеш вообще не настроен. Проверяйте в обычном окне или через curl -I.

Итоги

  • Кеш — самая дешёвая оптимизация: он убирает запрос целиком, а не ускоряет его. Ноль байтов, ноль RTT, ноль нагрузки на сервер.
  • Всей политикой управляет заголовок Cache-Control. no-cache — «кешируй, но валидируй», no-store — «не сохраняй». Это разные вещи.
  • ETag + 304 Not Modified экономят трафик, но не экономят круг по сети. Это компромисс, а не цель.
  • Рабочая схема на два яруса: статика с хешем в имени — public, max-age=31536000, immutable; HTML — no-cache.
  • Хеш в имени файла делает вечный кеш безопасным: новая версия — новый URL, старый кеш не мешает.
  • Проверяйте настройку через curl -I, а не глазами в DevTools с включённым «Disable cache».
Проверьте себя
1. Чем директива no-cache отличается от no-store?
AЭто синонимы, обе полностью запрещают кеширование ответа
Bno-cache разрешает сохранить ответ, но требует валидации перед каждым использованием; no-store запрещает сохранять ответ вообще
Cno-cache запрещает кеш браузеру, а no-store — только CDN
Dno-store разрешает кешировать на диск, но не в память
2. Почему статике с хешем в имени файла (index-4f2a9c1e.js) безопасно ставить max-age=31536000, immutable?
AПотому что браузер всё равно перепроверит файл при каждом обновлении страницы
BПотому что содержимое по этому URL измениться не может: при изменении кода меняется хеш, а значит и сам URL
CПотому что immutable отменяет действие max-age
DПотому что год — это максимальный срок, после которого кеш очищается принудительно