
# Почему ваш CDN ничего не кеширует

Каждый статический файл agentready.md возвращался с одной и той же строкой:

```
cf-cache-status: BYPASS
```

Шрифты, стили, скрипты, картинки. Не `MISS` — MISS означал бы, что край ещё не видел файл и оставит у себя только что забранную копию. `BYPASS` означает, что край прочитал ответ, решил, что не имеет права его хранить, и завтра решит так же. Каждый посетитель тянул все байты с origin и тянул бы их вечно.

## Четыре настройки, которые были ни при чём

- **Development Mode** — выключен. Он полностью гасит кеш на краю на три часа, и его часто забывают выключить, так что смотреть его нужно первым.
- **Caching Level** — Standard.
- **Page Rules** — ни одно не ставило Cache Level в Bypass.
- **Browser Cache TTL** — вменяемое значение.

Всё, что панель говорила про кеш, говорило *храни это*. Край не хранил ничего.

## Причина была в ответе, а не в панели

Она видна сразу, если прочитать весь блок заголовков, а не ту единственную строку, за которой пришли:

```bash
curl -sI https://agentready.md/fonts/ibm-plex-sans-latin.woff2
```

```
HTTP/2 200
content-type: font/woff2
cache-control: public, max-age=31536000, immutable
set-cookie: _csrf=8f3c…; Path=/; SameSite=Strict
cf-cache-status: BYPASS
```

Третья строка просит год. Четвёртая объясняет, почему его никто не даёт.

**Ответ с `Set-Cookie` нельзя хранить в общем кеше.** Кука принадлежит одному посетителю; сохранить такой ответ и отдать его следующему — значит вручить ему чужую куку. Отказывается любой CDN. Cloudflare даёт отказу имя `BYPASS`, остальные просто молча не сохраняют объект.

Кука была наша. Мидлвара генерировала CSRF-токен на каждый запрос и прикладывала его, чтобы форма, отрисованная в любом месте сайта, нашла токен готовым. В «каждый запрос» попадал и запрос шрифта woff2 — файла, который не рисует форм, не читает токенов и не выполняет кода.

## Исправление

CSRF остаётся как был. Просто токен перестаёт выдаваться тому, кто не может им воспользоваться:

```js
const ASSET_PREFIXES = ['/css/', '/js/', '/fonts/', '/images/'];
const COOKIELESS_FILES = new Set([
  '/favicon.ico', '/badge.js', '/robots.txt', '/sitemap.xml', '/llms.txt',
]);

function servesWithoutCookie(path) {
  return COOKIELESS_FILES.has(path)
    || ASSET_PREFIXES.some((prefix) => path.startsWith(prefix))
    || path.endsWith('.md');
}
```

Сравнивайте пути, а не расширения. Часть ответов, которым кеш нужнее всего, не читается с диска, а собирается шаблоном: markdown-двойник каждой страницы, наши SVG-бейджи. Форм они тоже не рисуют.

Если куку ставили не вы, посмотрите на слой сессий. Немало фреймворков открывают сессию и отправляют её куку на любой запрос, дошедший до мидлвары, даже если внутрь ничего так и не положили.

## Как это диагностировать на любом стеке

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

```bash
for u in / /css/app.css /js/app.js /fonts/ibm-plex-sans-latin.woff2; do
  echo "== $u"
  curl -sI "https://example.com$u" \
    | grep -iE 'cache-control|set-cookie|cf-cache-status|x-cache|^age'
done
```

Запросите один и тот же URL дважды. Второй раз должен быть попаданием: `cf-cache-status: HIT`, `x-cache: Hit from cloudfront` или растущий заголовок `age:` в Varnish и nginx. Второй промах означает, что объект не сохраняется, и причина — в заголовках, которые вы только что вывели.

## Остальные обычные подозреваемые

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

`Vary: *` делает ответ некешируемым сразу. `Vary: Cookie` почти так же плох: в ключ кеша попадает значение, своё у каждого посетителя, и общего не остаётся ничего. `Vary: User-Agent` дробит один файл на тысячи вариантов. Уместен здесь `Accept-Encoding`.

Строки запроса. Большинство кешей ключуется по полному URL, поэтому каждый вариант с `?utm_source=` или `?fbclid=` — отдельный объект, забираемый с origin. Срезайте или нормализуйте рекламные параметры на краю.

И дефолт фреймворка: не bypass, но обходится почти так же дорого. `max-age=0` на каждом статическом файле означает по запросу на перепроверку для каждого ассета при каждом переходе.

## Политика кеширования, которая держится

Правило про то, что обещает URL, а не про то, чем является файл.

URL с хешем содержимого (`app.css?v=9f2ac41b`, `app.9f2ac41b.css`) — это адрес содержимого: изменился файл, изменился и URL. Таким ставим `public, max-age=31536000, immutable`, и очищать никогда ничего не придётся. Тот же файл без токена получает час: токен и есть обещание, а кешировать на основании обещания, которого никто не давал, незачем. Логотип или обложку для соцсетей, которые меняют на том же месте, — на неделю.

Остаётся исключение, на котором ошибаются чаще всего, потому что файл выглядит самым кешируемым из всего, что у вас есть: то, что встроено в чужие страницы по URL, которому вы не можете дать версию. Мы отдаём `badge.js` на час ровно поэтому. Он работает внутри сайтов, где мы не управляем ни HTML, ни URL, так что год `immutable` означал бы баг, до которого год не дотянуться.

## Если вы раздаёте файлы через @fastify/static

Две частности, стоившие нам половины дня. По умолчанию отправляется `Cache-Control: public, max-age=0`. И если заголовок ставите вы сами через `setHeaders`, передайте заодно `cacheControl: false`, иначе библиотека соберёт свой из дефолтного `maxAge` и перезапишет ваш — молча, оставив то, что выглядит как осознанное решение:

```js
app.register(fastifyStatic, {
  root: join(__dirname, 'public'),
  cacheControl: false,           // иначе setHeaders будет перезаписан
  setHeaders: setStaticCacheHeaders,
});
```

Прежде чем трогать хоть одну настройку кеша, прочитайте один ответ целиком. Заголовок, который ломает кеширование, редко тот, в имени которого есть слово cache.

[Проверить сайт](/ru) · [Чек-лист готовности к ИИ](/ru/tools/ai-readiness-checklist) · [Пускают ли ботов?](/ru/tools/ai-crawlers-checker)
