Diil Docs
  1. Документация
  2. Руководства

Кэш, ETag и 304: быстро и всегда свежее

Обновлено:

Обычно контентный API ставит перед выбором: либо быстро, либо свежо. Мы выбирать не хотим. Ответы кэшируются на нашей стороне, но там никогда не устаревают; у каждого ответа есть ETag, так что неизменившийся контент почти ничего вам не стоит; а правка в CRM попадает в API в ту же секунду, когда редактор нажал «Сохранить». Ниже — как это устроено и как выжать из этого максимум у себя.

Путь правки: от CRM до вашей страницы

Вот весь маршрут изменения — от клавиатуры редактора до экрана посетителя:

  1. Редактор сохраняет

    Кто-то поправил опечатку в CRM или прямо на живом сайте. Считается любая правка: текст, картинка, настройки страницы, статья в блоге.
  2. Версия контента сайта растёт

    Каждая правка увеличивает версию контента сайта. Наш серверный кэш (Redis) привязан к этой версии, поэтому все старые ответы разом становятся неактуальными. Никому не нужно «сбрасывать кэш».
  3. Следующий запрос получает свежие данные

    Ближайший же запрос к API собирает ответ уже из нового контента. С нашей стороны задержки нет вообще: не нужно ждать истечения TTL, нет очереди на очистку.
  4. Кэши между нами и посетителем догоняют

    То, что видит посетитель, может чуть отставать: браузер или CDN держат прошлый ответ до 60 секунд и могут показать его ещё разок, пока тихо забирают новый в фоне (это и есть stale-while-revalidate). Если у вас есть свой серверный кэш — к этому прибавляется его срок жизни.

Заголовки кэширования по порядку

Обычный успешный ответ приходит с такими заголовками:

200 OK — заголовки ответаhttp
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: public, max-age=60, stale-while-revalidate=600
ETag: W/"a41f9c0e7b2d58f3"
Vary: x-crm-key, Accept-Encoding
Access-Control-Allow-Origin: *
ЗаголовокЧто он говорит кэшам
Cache-Control: publicОтвет можно хранить кому угодно: браузеру, CDN, прокси. Контент и так публичный — вы же показываете его на сайте.
max-age=6060 секунд ответ считается свежим, и его можно отдавать повторно, вообще не спрашивая нас.
stale-while-revalidate=600Следующие 10 минут кэш может мгновенно отдать старую копию и параллельно сходить за новой. Этому посетителю — быстро, следующему — свежо.
ETag: W/"…"Слабый ETag — хэш тела ответа. Тело то же — и ETag тот же. Пришлите его обратно в If-None-Match, и если ничего не поменялось, получите 304.
Vary: x-crm-key, Accept-EncodingКэш обязан хранить отдельные копии для каждого ключа сайта и каждого сжатия. Два сайта никогда не получат чужой ответ, даже по одному и тому же адресу.
Cache-Control: no-storeПриходит с каждой ошибкой. 404 на страницу, которую редактор прямо сейчас создаёт, не должен застрять у кого-то в кэше.

CORS открыт для любого источника, API явно разрешает заголовок запроса If-None-Match и отдаёт ETag в JavaScript — так что всё описанное здесь работает и из браузера.

ETag и ответ 304 Not Modified

ETag — самый дешёвый способ спросить «что-нибудь поменялось?». Запомните ETag из прошлого ответа, в следующий раз отправьте его в If-None-Match — и если контент тот же, придёт 304 Not Modified с пустым телом. Ваш код просто продолжает пользоваться копией, которая у него уже есть.

Первый запрос: полный ответhttp
GET /content/v1/pages/home?lang=en HTTP/1.1
Host: back.sitecog.com
x-crm-key: pk_3f9c2a7e1b4d4c0e8a6f5b2d9c1e7a40

HTTP/1.1 200 OK
Cache-Control: public, max-age=60, stale-while-revalidate=600
ETag: W/"a41f9c0e7b2d58f3"
Vary: x-crm-key, Accept-Encoding

{ "id": 26, "marker": "home", "name": "Home", "content": { … } }
Следующий запрос: ничего не изменилосьhttp
GET /content/v1/pages/home?lang=en HTTP/1.1
Host: back.sitecog.com
x-crm-key: pk_3f9c2a7e1b4d4c0e8a6f5b2d9c1e7a40
If-None-Match: W/"a41f9c0e7b2d58f3"

HTTP/1.1 304 Not Modified
Cache-Control: public, max-age=60, stale-while-revalidate=600
ETag: W/"a41f9c0e7b2d58f3"

Стоит редактору что-то поменять на этой странице — меняется тело, меняется хэш, и тот же запрос вернёт свежий 200 с новым ETag.

Рецепты кэширования

В браузере: уже всё сделано

Если вы запрашиваете контент прямо из браузера, писать код кэширования не нужно вовсе. Обычный fetch пользуется HTTP-кэшем браузера: 60 секунд отдаёт сохранённый ответ, а потом сам перепроверяет его через If-None-Match.

Браузер — ничего настраивать не нужноjs
const res = await fetch('https://back.sitecog.com/content/v1/pages/home?lang=en', {
  headers: { 'x-crm-key': 'pk_3f9c2a7e1b4d4c0e8a6f5b2d9c1e7a40' },
  // cache: 'default' стоит по умолчанию — max-age и ETag браузер обработает сам
});
const page = await res.json();

Не удивляйтесь, что ваш код никогда не видит 304: браузер подменяет его закэшированным 200 ещё до того, как ответ дойдёт до вас. Что произошло на самом деле, видно во вкладке Network в DevTools — там и будут 304 или «disk cache».

На сервере Node: маленький кэш с ETag

Серверный fetch в Node своего HTTP-кэша не имеет, так что каждый вызов идёт прямиком в API. Это лечится маленьким Map: 60 секунд отдаём копию (ровно как max-age), потом спрашиваем через If-None-Match и скачиваем тело, только если оно поменялось.

lib/content.ts — Express, Fastify, Nuxt, Remix, что угодно на Nodets
const API = 'https://back.sitecog.com/content';
const TTL = 60_000; // как max-age=60

type Entry = { etag: string | null; data: unknown; at: number };
// Один процесс — один ключ сайта. Ключей несколько? Добавьте ключ в ключ кэша.
const cache = new Map<string, Entry>();

export async function getContent<T>(path: string): Promise<T> {
  const url = API + path;
  const cached = cache.get(url);

  // 1. Копия ещё свежая — в API даже не ходим
  if (cached && Date.now() - cached.at < TTL) return cached.data as T;

  // 2. Спрашиваем «изменилось?» с ETag, который у нас уже есть
  const headers: Record<string, string> = { 'x-crm-key': process.env.CRM_KEY! };
  if (cached?.etag) headers['if-none-match'] = cached.etag;

  const res = await fetch(url, { headers });

  if (res.status === 304 && cached) {
    cached.at = Date.now(); // контент тот же — ещё 60 секунд спокойствия
    return cached.data as T;
  }
  if (!res.ok) throw new Error(`Content API ${res.status} for ${path}`);

  const data = (await res.json()) as T;
  cache.set(url, { etag: res.headers.get('etag'), data, at: Date.now() });
  return data;
}

// использование
const home = await getContent('/v1/pages/home?lang=en');

Next.js: revalidate и обновление по требованию

В App Router всю работу делает кэш fetch. revalidate: 60 совпадает с нашим max-age: Next.js держит ответ минуту, а потом обновляет его в фоне.

app/page.tsxtsx
export default async function Home() {
  const res = await fetch('https://back.sitecog.com/content/v1/pages/home?lang=en', {
    headers: { 'x-crm-key': process.env.CRM_KEY! },
    next: { revalidate: 60, tags: ['crm-content'] },
  });
  const page = await res.json();

  return <h1>{page.content.hero.content.hero_title.content.en}</h1>;
}

Хотите, чтобы правки доходили быстрее? Уменьшите revalidate, но поглядывайте на лимиты. Нужна кнопка «опубликовать прямо сейчас» к большому запуску? Повесьте на запросы тег и заведите маленький маршрут, который этот тег сбрасывает:

app/api/revalidate/route.tsts
import { revalidateTag } from 'next/cache';

export async function POST(req: Request) {
  if (req.headers.get('x-revalidate-secret') !== process.env.REVALIDATE_SECRET) {
    return new Response('Nope', { status: 401 });
  }
  // В Next.js 16 нужен второй аргумент; в Next.js 15 — просто revalidateTag('crm-content')
  revalidateTag('crm-content', { expire: 0 });
  return Response.json({ revalidated: true });
}

Дёргайте его откуда удобно команде: из скрипта выкладки, из закладки в браузере, из чат-бота, кнопкой в своей админке. Секрет — только ваш, с ключом сайта он никак не связан.

Режим Live: мимо всех кэшей

Когда редактор открывает ваш сайт в режиме Live в CRM, он хочет увидеть правку сразу после сохранения, а не через минуту. Внутри рамки CRM к адресу добавляется ?crm_live=1. Хорошая практика (и ровно так делает наш эталонный клиент): если страница открыта в iframe или в адресе есть crm_live, запрашивайте с cache: 'no-store'. Наша сторона и так свежая, больше ничего не нужно.

const isLive =
  window.self !== window.top ||
  new URLSearchParams(location.search).has('crm_live');

const res = await fetch('https://back.sitecog.com/content/v1/pages/home?lang=en', {
  headers: { 'x-crm-key': 'pk_3f9c2a7e1b4d4c0e8a6f5b2d9c1e7a40' },
  cache: isLive ? 'no-store' : 'default',
});

Подробнее о режиме Live и разметке для него — на странице Виджет и разметка.

«Почему я не вижу свою правку?»

Самый популярный вопрос про любой кэш. Пройдитесь по списку сверху вниз: он идёт от «проверить за десять секунд» до «заварите себе чаю».

  1. Спросите API напрямую. У обычного curl кэша нет, а наша сторона всегда свежая. Если curl показывает новый текст — с API всё в порядке, старая копия застряла в каком-то кэше по дороге. Если старый — идите дальше по списку.
  2. Правка точно сохранилась? Проверьте в CRM. Для блога: черновики не отдаются никогда, а статья, запланированная на будущее, скрыта до своей даты (и может появиться с опозданием на несколько минут).
  3. Тот ли сайт? Ключ принадлежит ровно одному сайту. Тестовая и боевая версии с разными ключами читают разный контент.
  4. Тот ли язык? Запасного языка на сервере нет. Если редактор поправил немецкий текст, а вы выводите английский, ничего видимого не произойдёт. Пустой перевод приходит как "", и ваш код подстановки может молча показать вместо него другой язык. Подробно — в разделе Языки и запасной язык.
  5. Тот ли блок? Маркеры чувствительны к регистру, а маркер блока уникален только внутри секции: /v1/blocks/title без ?section= вернёт самый старый блок с таким маркером — и это может быть вовсе не тот, который правили.
  6. Секция скрыта? Секции с show: false всё равно приходят в ответе. Если редактор скрыл секцию, а на сайте она осталась, — ваш шаблон не смотрит на show.
  7. Ваш собственный кэш. revalidate, ISR, Map в памяти, CDN перед сайтом, страница, собранная один раз при сборке. Главный подозреваемый.
  8. Браузер. До 60 секунд плюс одно фоновое обновление. Жёсткая перезагрузка (Ctrl+Shift+R или Cmd+Shift+R) решает вопрос.
  9. Правите в режиме Live? Убедитесь, что внутри рамки CRM запросы идут с cache: 'no-store' (см. выше).
Что API отдаёт прямо сейчасbash
curl -i "https://back.sitecog.com/content/v1/pages/home?lang=en" \
  -H "x-crm-key: pk_3f9c2a7e1b4d4c0e8a6f5b2d9c1e7a40"