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