Caching стратегии

Кэширование — хранение копий ресурсов (файлов, ответов API) ближе к пользователю, чтобы повторные запросы обрабатывались быстрее. Правильная стратегия кэширования ускоряет загрузку в 10-100 раз для повторных визитов.

Зачем нужно

Без кэширования браузер каждый раз загружает одни и те же CSS, JS, изображения. На средней странице это 50-100 запросов и 2-5 MB трафика. Кэширование сокращает время загрузки повторного визита с 3-5 секунд до 200-500 мс. Это напрямую влияет на LCP, TTFB и пользовательский опыт.

Где используется

Все веб-сайты и приложения. Браузерный кэш, CDN, Service Workers, прокси-серверы (nginx, Varnish), серверный кэш (Redis, Memcached). Кэширование работает на каждом уровне: DNS, TCP, HTTP, приложение.

Предпосылки

HTTP-протокол, Web Vitals, Browser rendering flow, базовое понимание nginx или другого веб-сервера

Уровни кэширования

Пользователь
    │
    ▼
[Browser Cache]     — Локальный кэш в браузере
    │
    ▼
[Service Worker]    — Программируемый кэш (перехват fetch)
    │
    ▼
[CDN / Edge]        — Кэш на ближайшем сервере к пользователю
    │
    ▼
[Reverse Proxy]     — nginx, Varnish перед сервером
    │
    ▼
[Application Cache] — Redis, Memcached на уровне приложения
    │
    ▼
[Origin Server]     — Исходный сервер (БД, файлы)

HTTP Cache Headers

Cache-Control — главный заголовок

# Кэшировать на 1 год (статические ассеты с хешем в имени)
Cache-Control: public, max-age=31536000, immutable

# Кэшировать на 1 час (страницы, API-ответы)
Cache-Control: public, max-age=3600

# Всегда перепроверять (HTML-документы)
Cache-Control: no-cache

# Никогда не кэшировать (персональные данные, корзина)
Cache-Control: no-store

# Только браузер кэширует (не CDN)
Cache-Control: private, max-age=600

Директивы Cache-Control

Директива Значение
public Может кэшировать любой (браузер, CDN, прокси)
private Только браузер пользователя (не CDN)
max-age=N Кэш валиден N секунд
s-maxage=N max-age для CDN/прокси (перекрывает max-age)
no-cache Кэшировать, но ВСЕГДА перепроверять на сервере
no-store НИКОГДА не кэшировать (ни браузер, ни CDN)
must-revalidate После истечения max-age — обязательно перепроверить
immutable Ресурс НИКОГДА не изменится (не перепроверять)
stale-while-revalidate=N Отдать старый кэш, пока обновляется в фоне

Распространённая ошибка: no-cache vs no-store

# no-cache = "кэшируй, но каждый раз спрашивай сервер, актуален ли?"
Cache-Control: no-cache
# Браузер ХРАНИТ копию, но перед использованием делает запрос на валидацию

# no-store = "вообще не храни копию"
Cache-Control: no-store
# Браузер НЕ хранит ничего, каждый раз полная загрузка

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

ETag / If-None-Match

Первый запрос:
  GET /styles.css
  → 200 OK
  → ETag: "abc123"
  → Cache-Control: no-cache

Повторный запрос:
  GET /styles.css
  If-None-Match: "abc123"

  Если файл НЕ изменился:
  → 304 Not Modified (тело НЕ передаётся — экономия трафика)

  Если файл изменился:
  → 200 OK
  → ETag: "def456"
  → (новое тело)
// Express.js — ETag включён по умолчанию
const express = require('express');
const app = express;

// ETag генерируется автоматически для статики
app.use(express.static('public'));

// Для API-ответов — вручную
app.get('/api/data', (req, res) => {
  const data = getExpensiveData;
  const hash = crypto.createHash('md5').update(JSON.stringify(data)).digest('hex');

  res.set('ETag', `"${hash}"`);
  res.set('Cache-Control', 'no-cache');

  // Проверяем If-None-Match
  if (req.headers['if-none-match'] === `"${hash}"`) {
    return res.status(304).end();
  }

  res.json(data);
});

Last-Modified / If-Modified-Since

Первый запрос:
  GET /data.json
  → 200 OK
  → Last-Modified: Mon, 07 Apr 2026 10:00:00 GMT

Повторный запрос:
  GET /data.json
  If-Modified-Since: Mon, 07 Apr 2026 10:00:00 GMT

  → 304 Not Modified (если не менялся)
  → 200 OK + новые данные (если менялся)
ETag vs Last-Modified:
- ETag — точнее (хеш контента), но дороже вычислять
- Last-Modified — проще, но точность до секунды
- Рекомендация: используй оба, ETag приоритетнее

Стратегии для разных типов ресурсов

1. HTML-документы — всегда перепроверять

Cache-Control: no-cache
ETag: "page-v42"

HTML — точка входа. Если браузер покажет старый HTML, он загрузит старые CSS/JS. Поэтому HTML всегда перепроверяется, но хранится в кэше для быстрых 304.

2. Статические ассеты с хешем — кэш навсегда

# styles.a1b2c3.css, main.d4e5f6.js, logo.g7h8i9.webp
Cache-Control: public, max-age=31536000, immutable

Хеш в имени файла = при изменении контента меняется URL. Старый кэш не используется, потому что HTML запрашивает новый URL. Можно кэшировать навечно.

3. API-ответы — зависит от данных

# Редко меняющиеся данные (каталог товаров)
Cache-Control: public, max-age=300, stale-while-revalidate=60

# Персональные данные (профиль пользователя)
Cache-Control: private, no-cache

# Реалтайм данные (цены, баланс)
Cache-Control: no-store

4. Шрифты — долгий кэш

# Шрифты меняются крайне редко
Cache-Control: public, max-age=31536000, immutable

Cache Busting — инвалидация кэша

Хеш в имени файла (лучший способ)

// webpack.config.js
module.exports = {
  output: {
    filename: '[name].[contenthash].js',  // main.a1b2c3d4.js
    chunkFilename: '[name].[contenthash].js',
  },
  plugins: [
    new MiniCssExtractPlugin({
      filename: '[name].[contenthash].css', // styles.e5f6g7h8.css
    }),
  ],
};

// vite.config.js — хеш по умолчанию в production build
<!-- Результат: -->
<link rel="stylesheet" href="/styles.a1b2c3d4.css">
<script src="/main.e5f6g7h8.js"></script>
<!-- При изменении кода — новый хеш — новый URL — загрузка свежего файла -->

Query string (менее надёжный)

<!-- Работает, но CDN может игнорировать query string -->
<link rel="stylesheet" href="/styles.css?v=42">
<script src="/app.js?v=20260407"></script>

Версия в пути (для API)

/api/v1/users  →  /api/v2/users
/static/v3/styles.css

Nginx — конфигурация кэширования

server {
    listen 80;
    server_name example.com;
    root /var/www/html;

    # HTML — всегда перепроверять
    location ~* \.html$ {
        add_header Cache-Control "no-cache";
        etag on;
    }

    # Статика с хешем — кэш на год
    location ~* \.[0-9a-f]{8,}\.(js|css|woff2|webp|avif|png|jpg|svg)$ {
        add_header Cache-Control "public, max-age=31536000, immutable";
        access_log off;
    }

    # Обычная статика (без хеша) — кэш на неделю с проверкой
    location ~* \.(js|css|woff2|webp|png|jpg|svg|ico)$ {
        add_header Cache-Control "public, max-age=604800, must-revalidate";
        etag on;
    }

    # API — без кэша для динамических данных
    location /api/ {
        add_header Cache-Control "no-store";
        proxy_pass http://backend;
    }

    # Gzip / Brotli для уменьшения размера
    gzip on;
    gzip_types text/css application/javascript application/json image/svg+xml;
    gzip_min_length 1000;
}

CDN Caching

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

Пользователь (Москва)
    │
    ▼
[CDN Edge — Москва]   ← кэш ближе к пользователю
    │ (cache miss)
    ▼
[CDN Origin Shield]   ← промежуточный кэш (уменьшает нагрузку на origin)
    │
    ▼
[Origin Server — Frankfurt]

CDN-заголовки

# s-maxage — для CDN отдельный TTL
Cache-Control: public, max-age=300, s-maxage=86400
# Браузер: кэш 5 минут
# CDN: кэш 24 часа

# stale-while-revalidate — CDN отдаёт старый, обновляет в фоне
Cache-Control: public, max-age=60, stale-while-revalidate=300
# 0-60с: свежий кэш
# 60-360с: отдаёт старый, обновляет в фоне
# >360с: ждёт обновления

# Vary — кэшировать разные версии по заголовку
Vary: Accept-Encoding
# CDN хранит gzip и brotli версии отдельно

# Surrogate-Control — для CDN (не передаётся браузеру)
Surrogate-Control: max-age=86400
Cache-Control: no-cache
# CDN кэширует на сутки, браузер всегда проверяет

Инвалидация CDN

# Cloudflare — очистка кэша
curl -X POST "https://api.cloudflare.com/client/v4/zones/{zone_id}/purge_cache" \
  -H "Authorization: Bearer {token}" \
  -d '{"files":["https://example.com/styles.css"]}'

# AWS CloudFront
aws cloudfront create-invalidation \
  --distribution-id EDFDVBD6EXAMPLE \
  --paths "/images/*" "/css/*"

Service Worker Cache

// Подробнее — [[Service Workers]]
// Краткий обзор стратегий:

// Cache First — для статических ассетов
self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request)
      .then(cached => cached || fetch(event.request))
  );
});

// Network First — для API и свежих данных
self.addEventListener('fetch', (event) => {
  event.respondWith(
    fetch(event.request)
      .catch( => caches.match(event.request))
  );
});

// Stale While Revalidate — для баланса скорости и свежести
self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.open('dynamic').then(cache =>
      cache.match(event.request).then(cached => {
        const networkFetch = fetch(event.request)
          .then(response => {
            cache.put(event.request, response.clone());
            return response;
          });
        return cached || networkFetch;
      })
    )
  );
});

Сводная таблица стратегий

Ресурс Cache-Control ETag Cache Bust Пример
HTML no-cache Да Нет index.html
CSS/JS (с хешем) max-age=31536000, immutable Нет Хеш в имени app.a1b2.js
CSS/JS (без хеша) max-age=604800, must-revalidate Да Query string styles.css?v=3
Изображения (с хешем) max-age=31536000, immutable Нет Хеш в имени logo.c3d4.webp
Шрифты max-age=31536000, immutable Нет Версия в пути /fonts/v2/inter.woff2
API (публичный) max-age=300, s-maxage=3600 Да Нет /api/catalog
API (приватный) private, no-cache Да Нет /api/profile
API (реалтайм) no-store Нет Нет /api/balance

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

1. max-age без cache busting

# ПЛОХО: кэш на год, но имя файла не меняется
Cache-Control: max-age=31536000
# styles.css закэшируется, и обновить его НЕВОЗМОЖНО целый год

# ХОРОШО: кэш на год + хеш
# styles.a1b2c3.css
Cache-Control: max-age=31536000, immutable
# Новая версия — новый файл — новый запрос

2. no-cache вместо no-store для секретных данных

# ПЛОХО: банковские данные кэшируются (но перепроверяются)
Cache-Control: no-cache
# Данные остаются на диске! Другой пользователь ПК может увидеть

# ХОРОШО: данные не хранятся нигде
Cache-Control: no-store

3. Забытый Vary заголовок

# ПЛОХО: CDN кэширует только одну версию
Cache-Control: public, max-age=3600
# Если первый запрос без gzip — все получат несжатую версию

# ХОРОШО: разные версии для разных Accept-Encoding
Cache-Control: public, max-age=3600
Vary: Accept-Encoding

4. Кэширование ошибок

// ПЛОХО: 500-ошибка закэшировалась на CDN
app.get('/api/data', (req, res) => {
  res.set('Cache-Control', 'public, max-age=3600');
  res.json(getData); // А если упадёт?
});

// ХОРОШО: кэшируем только успешные ответы
app.get('/api/data', (req, res) => {
  try {
    const data = getData;
    res.set('Cache-Control', 'public, max-age=3600');
    res.json(data);
  } catch (err) {
    res.set('Cache-Control', 'no-store');
    res.status(500).json({ error: 'Server error' });
  }
});

Практика

  1. Настрой Cache-Control для HTML (no-cache), CSS/JS с хешем (immutable, 1 год) и API (5 минут)
  2. Реализуй ETag для API-ответа в Express и проверь 304 в DevTools Network tab
  3. Настрой nginx для кэширования статики с разными правилами для файлов с хешем и без
  4. Проверь кэширование своего сайта через DevTools → Network → столбец Size (disk cache / memory cache)
  5. Настрой cache busting через contenthash в webpack или Vite

Связанные темы

Ресурсы