Кэширование статики — это одна из тех вещей, которую делают почти все, но делают правильно единицы. За 10 лет практики я видел проекты, где картинки грузились с нуля при каждом запросе, CSS-файлы не кэшировались вообще, а CDN стоял за сотни долларов в месяц и фактически работал как обычный прокси без какой-либо пользы.
Зачем вообще кэшировать статику и что это даёт
Статика — это всё, что не меняется от запроса к запросу: изображения, CSS, JS, шрифты, PDF-файлы, видео. Когда пользователь заходит на сайт, браузер делает десятки запросов к серверу. Без кэширования каждый из них — это полноценный HTTP-запрос с установкой соединения, передачей заголовков, ожиданием ответа и скачиванием тела. С нормально настроенным кэшем большинство этих запросов просто не уходят на сервер — браузер берёт файл из своего локального хранилища.
На деле разница может быть огромной. У меня был клиент — интернет-магазин на Битриксе, около 800 товаров, каждая карточка с 5-6 фотографиями. PageSpeed Insights показывал 34 балла на мобильных. После настройки правильных заголовков Cache-Control и подключения CDN — 71 балл. Без единой правки кода, просто за счёт кэширования и правильной раздачи статики. Время загрузки первого байта (TTFB) осталось прежним, но общее время загрузки страницы упало с 8.2 секунды до 2.9.
Кэширование решает сразу несколько задач: снижает нагрузку на сервер, уменьшает трафик, ускоряет загрузку для пользователей и улучшает поведенческие факторы — а это уже влияет на SEO. Если вы ещё не разобрались с Core Web Vitals, то правильное кэширование статики — один из самых быстрых способов улучшить LCP и FID.
Как работают заголовки Cache-Control: разбираю по-человечески
Заголовок Cache-Control — это инструкция, которую сервер отдаёт браузеру (и промежуточным прокси, CDN) о том, как кэшировать ответ. Звучит просто, но там есть много нюансов, которые путают даже опытных разработчиков.
Основные директивы, которые я использую на практике:
- max-age=N — кэшировать N секунд с момента запроса. Самая распространённая директива.
- s-maxage=N — то же самое, но только для общих кэшей (CDN, прокси). Браузер игнорирует это значение и смотрит на max-age.
- public — разрешает кэшировать ответ в общих кэшах, включая CDN. Без этого CDN может не закэшировать файл.
- private — только в браузере пользователя, не в CDN. Используется для персонализированного контента.
- no-cache — не путать с "не кэшировать"! Это значит "кэшируй, но каждый раз проверяй актуальность у сервера". Браузер отправит условный запрос с
If-Modified-SinceилиETag. - no-store — вот это уже "не кэшировать вообще". Никогда. Используется для чувствительных данных.
- immutable — файл никогда не изменится, не нужно проверять актуальность даже при hard refresh. Мощная штука, но нужно понимать, когда применять.
- stale-while-revalidate=N — отдавать устаревший кэш, пока в фоне обновляется. Очень полезно для производительности.
Главная ошибка, которую я вижу постоянно — путаница между no-cache и no-store. Разработчик хочет "не кэшировать", пишет no-cache, и удивляется, почему старая версия файла всё равно иногда появляется. А на деле no-cache просто заставляет браузер каждый раз делать условный запрос — но если сервер ответил 304 Not Modified, браузер возьмёт файл из кэша.
Cache-Control: no-store.Настройка Nginx для кэширования статики: конфигурация из практики
Nginx — мой основной веб-сервер на всех проектах. На PHP 8.1, 8.2, 8.3 я всегда настраиваю кэширование статики через location-блоки. Вот конфиг, который я использую как базу:
server {
listen 80;
server_name example.com;
root /var/www/html;
# Статические файлы с долгим кэшем
location ~* \.(jpg|jpeg|png|gif|ico|svg|webp|avif)$ {
expires 1y;
add_header Cache-Control "public, max-age=31536000, immutable";
add_header Vary "Accept-Encoding";
access_log off;
}
# CSS и JS — тоже долгий кэш, но без immutable
# (если не используете cache busting через хэш в имени файла)
location ~* \.(css|js)$ {
expires 1y;
add_header Cache-Control "public, max-age=31536000, immutable";
add_header Vary "Accept-Encoding";
access_log off;
}
# Шрифты
location ~* \.(woff|woff2|ttf|eot|otf)$ {
expires 1y;
add_header Cache-Control "public, max-age=31536000, immutable";
add_header Access-Control-Allow-Origin "*";
access_log off;
}
# PDF и документы
location ~* \.(pdf|doc|docx|xls|xlsx)$ {
expires 30d;
add_header Cache-Control "public, max-age=2592000";
}
# HTML — короткий кэш или без кэша
location ~* \.html$ {
expires 1h;
add_header Cache-Control "public, max-age=3600, stale-while-revalidate=86400";
}
}
Несколько важных моментов по этому конфигу. Директива immutable говорит браузеру: "этот файл никогда не изменится, даже если пользователь нажмёт Ctrl+F5 — не нужно делать запрос на сервер". Это работает только если вы используете cache busting — то есть при каждом деплое имена файлов меняются (например, app.a3f9d2.css вместо app.css). В WordPress это делает встроенная система с параметром ?ver=, в Битриксе — собственный механизм версионирования.
Строчка access_log off для статики — не обязательна, но рекомендую. Логи статики занимают огромное место и практически никогда не нужны для анализа. На проекте с 50k уников в день логи статики могут занимать по 2-3 ГБ в сутки.
Заголовок Vary: Accept-Encoding важен, если вы отдаёте сжатые версии файлов. Он говорит CDN: "кэшируй разные версии для разных значений Accept-Encoding". Без него CDN может отдать сжатый файл браузеру, который не поддерживает gzip. О настройке сжатия я писал в статье про Gzip и Brotli — рекомендую прочитать в связке с этой статьёй.
Настройка .htaccess для Apache: для тех, кто на shared-хостинге
Не все работают на VPS с Nginx. Значительная часть сайтов, особенно небольших WordPress-проектов, стоит на shared-хостинге с Apache. Там управлять кэшированием нужно через .htaccess:
# Включаем mod_expires
<IfModule mod_expires.c>
ExpiresActive On
# Изображения
ExpiresByType image/jpeg "access plus 1 year"
ExpiresByType image/png "access plus 1 year"
ExpiresByType image/gif "access plus 1 year"
ExpiresByType image/webp "access plus 1 year"
ExpiresByType image/svg+xml "access plus 1 year"
ExpiresByType image/x-icon "access plus 1 year"
# Шрифты
ExpiresByType font/woff "access plus 1 year"
ExpiresByType font/woff2 "access plus 1 year"
ExpiresByType application/font-woff "access plus 1 year"
# CSS и JS
ExpiresByType text/css "access plus 1 year"
ExpiresByType application/javascript "access plus 1 year"
ExpiresByType text/javascript "access plus 1 year"
# HTML
ExpiresByType text/html "access plus 1 hour"
</IfModule>
# Явно задаём Cache-Control через mod_headers
<IfModule mod_headers.c>
<FilesMatch "\.(jpg|jpeg|png|gif|webp|svg|ico|woff|woff2|css|js)$">
Header set Cache-Control "public, max-age=31536000, immutable"
</FilesMatch>
<FilesMatch "\.html$">
Header set Cache-Control "public, max-age=3600, stale-while-revalidate=86400"
</FilesMatch>
</IfModule>
Честно говоря, с Apache всё чуть сложнее — нужно проверять, что на хостинге включены модули mod_expires и mod_headers. Обычно они есть, но бывают исключения. Если заголовки не применяются — напишите в поддержку хостинга.
CDN: как выбрать и правильно настроить
CDN (Content Delivery Network) — это сеть серверов по всему миру, которая хранит копии вашей статики и отдаёт их с ближайшего к пользователю узла. Если ваш сервер в Москве, а пользователь в Новосибирске — без CDN он ждёт ответа от Москвы. С CDN — от ближайшей точки, которая может быть в том же городе.
На российском рынке я чаще всего работаю с тремя CDN-провайдерами:
- Cloudflare — бесплатный тариф закрывает базовые потребности большинства сайтов. Есть нюанс: бесплатный тариф не кэширует HTML по умолчанию, только статику. Но для статики этого достаточно.
- Selectel CDN — российский провайдер, хорошо работает для аудитории из России и СНГ. Цены адекватные, поддержка отвечает по-русски.
- VK Cloud (Mail.ru CDN) — неплохой вариант, особенно если уже используете их облако.
Подробно про настройку CDN я разбирал в отдельной статье — пошаговое руководство по CDN. Здесь остановлюсь на ключевых моментах конфигурации.
Самое важное при настройке CDN — правильно задать правила кэширования на стороне CDN. Это называется Edge Cache TTL или Cache Rules, в зависимости от провайдера. Суть одна: нужно указать, какие URL кэшировать, на сколько времени, и что считать "одним ресурсом" (по каким параметрам разделять кэш).
В Cloudflare я обычно настраиваю Page Rules или Cache Rules так:
- URL
*.jpg, *.png, *.webp, *.css, *.js, *.woff2— Cache Level: Cache Everything, Edge Cache TTL: 1 month - URL с параметром
?nocache=1— Bypass Cache (для отладки) - Административные URL (
/wp-admin/*,/bitrix/admin/*) — Bypass Cache
Cache busting: стратегии версионирования файлов
Вот где большинство разработчиков совершают ошибку. Они ставят max-age=31536000 (год), а потом удивляются: "Почему у пользователей старый CSS после деплоя?". Ответ прост: браузер честно закэшировал файл на год и не собирается его перезапрашивать.
Решение — cache busting. Грубо говоря, это изменение имени файла или добавление параметра при каждом изменении содержимого. Три основных подхода:
1. Хэш в имени файла. Это лучший вариант. При сборке Webpack/Vite добавляет MD5-хэш содержимого к имени файла: app.a3f9d2c1.css. Файл изменился — хэш изменился — браузер загружает новый файл. Старый остаётся в кэше, но к нему никто не обращается. Именно при таком подходе можно смело ставить immutable.
2. Query string версионирование. WordPress делает именно так: style.css?ver=6.4.2. Технически это работает, но CDN иногда игнорирует query string при кэшировании (зависит от настроек). Надёжнее, чем ничего, но хуже хэша в имени.
3. Ручное версионирование. Самый ненадёжный способ. Разработчик вручную меняет версию в конфиге. Забыл поменять — старый файл у пользователей. Забудьте про это как про основной подход.
В Битриксе с его системой компиляции CSS/JS версионирование происходит автоматически при сбросе кэша. Но там есть нюанс — если кэш сброшен частично, некоторые файлы могут остаться со старыми именами. Про настройку кэширования в Битриксе у меня есть отдельная подробная статья.
ETag и Last-Modified: условные запросы
Даже с долгим max-age браузер иногда делает запрос к серверу — например, при принудительном обновлении страницы. В этом случае в ход идут условные запросы: браузер отправляет заголовок If-None-Match (со значением ETag) или If-Modified-Since (с датой последнего изменения). Сервер проверяет: файл изменился? Если нет — отвечает 304 Not Modified без тела ответа. Браузер берёт файл из кэша.
ETag — это уникальный идентификатор версии ресурса. Nginx генерирует его автоматически на основе времени изменения файла и его размера. Apache тоже генерирует, но по умолчанию включает в ETag inode файла — это плохо в кластерной среде, где у разных серверов один и тот же файл может иметь разные inode. В таком случае ETag будет разным на разных серверах, и условные запросы сломаются.
Для Apache на кластере я всегда добавляю в конфиг:
# Убираем inode из ETag (важно для кластера/балансировщика)
FileETag MTime Size
В Nginx эта проблема не актуальна — там ETag формируется только из времени изменения и размера.
Честно говоря, ETag vs Last-Modified — это не тот выбор, над которым стоит долго думать. Оба механизма работают, оба поддерживаются всеми браузерами. ETag чуть точнее (файл может быть изменён и сразу возвращён к прежнему виду — Last-Modified изменится, ETag нет). На практике разница несущественная.
Типичные проблемы и ошибки при настройке кэширования
За 10 лет я насобирал целую коллекцию кэш-граблей. Вот самые частые.
Кэшируется то, что не должно. Классика — закэшировался ответ API или страница корзины. Пользователь видит чужие товары в корзине. Это происходит, когда CDN настроен слишком агрессивно и кэширует все URL подряд. Решение: явно исключать динамические URL из кэша. На Cloudflare — через Cache Rules с Bypass. На Nginx — через proxy_no_cache для соответствующих location-блоков.
Разные версии файла у разных пользователей. После деплоя часть пользователей видит новую версию сайта, часть — старую. Причина обычно в том, что кэш CDN сброшен не полностью, или у некоторых пользователей файл закэширован в браузере. Именно поэтому cache busting через хэш в имени файла — лучшая практика.
CDN не кэширует файлы. Смотришь в заголовки ответа — CF-Cache-Status: MISS при каждом запросе. Причин может быть несколько: сервер отдаёт Cache-Control: private или no-store, или в URL есть куки/заголовки, которые CDN считает признаком персонализированного контента. Диагностируйте через curl с флагом -I и смотрите заголовки ответа.
Кэш не сбрасывается после обновления. Обновили плагин, поменяли CSS — а пользователи всё равно видят старое. Проверьте: правильно ли настроен cache busting, сброшен ли кэш CDN, нет ли дополнительного кэширующего слоя (Redis, Memcached, кэш на уровне PHP). На сложных проектах бывает 3-4 уровня кэша одновременно, и нужно сбрасывать все.
Проблемы с CORS для шрифтов. Шрифты загружаются с CDN-домена, а основной сайт на другом домене — браузер блокирует. Нужно добавить Access-Control-Allow-Origin: * для шрифтовых файлов. Я уже показал это в nginx-конфиге выше. Про CORS в целом — отдельная большая тема.
Неправильный Vary. Если сервер отдаёт разный контент в зависимости от User-Agent (например, разный HTML для мобильных и десктопных), но не ставит Vary: User-Agent — CDN закэширует одну версию и будет отдавать её всем. Это плохая идея в 2026 году (используйте адаптивный дизайн), но если приходится работать с legacy-проектом — не забывайте про Vary.
Инструменты для проверки и отладки кэширования
Как понять, что кэширование работает правильно? Несколько инструментов, которые я использую постоянно.
curl с заголовками. Самый простой способ посмотреть, что сервер реально отдаёт:
# Смотрим заголовки ответа
curl -I https://example.com/assets/app.css
# Смотрим заголовки при повторном запросе (с ETag)
curl -I -H 'If-None-Match: "abc123"' https://example.com/assets/app.css
# Проверяем конкретный CDN-узел
curl -I --resolve example.com:443:1.2.3.4 https://example.com/assets/app.css
В ответе смотрю на: Cache-Control, ETag, Last-Modified, CF-Cache-Status (для Cloudflare), Age (сколько секунд файл живёт в кэше CDN).
Chrome DevTools. Вкладка Network, колонка "Size" — если там написано "(disk cache)" или "(memory cache)", значит файл взят из кэша браузера. Колонка "Time" при закэшированном файле будет 0 или несколько миллисекунд.
Google PageSpeed Insights. Если кэширование настроено плохо, в разделе "Diagnostics" появится предупреждение "Serve static assets with an efficient cache policy". Там же будет список файлов с неправильными заголовками. Это быстрый способ найти проблемы. Про улучшение оценки PageSpeed я подробно писал в отдельной статье.
WebPageTest. Показывает waterfall загрузки, где видно, какие файлы кэшируются, а какие нет. Можно запустить повторный тест (Repeat View) — при правильном кэшировании повторная загрузка должна быть значительно быстрее первой.
Кэширование статики на разных CMS: WordPress, Битрикс, Laravel
На каждой платформе есть свои особенности.
WordPress. Статика раздаётся напрямую через Nginx/Apache, поэтому заголовки Cache-Control настраиваются на уровне веб-сервера — так, как я показал выше. Дополнительно можно использовать плагины: WP Rocket, W3 Total Cache, LiteSpeed Cache. Они умеют настраивать заголовки через PHP, минифицировать и объединять CSS/JS, что уменьшает количество файлов и упрощает кэширование. Но я предпочитаю настраивать кэш статики именно на уровне Nginx — это надёжнее и быстрее.
Битрикс. Тут есть нюанс: часть "статики" в Битриксе генерируется динамически через PHP (например, некоторые CSS-файлы проходят через /bitrix/cache/). Нужно внимательно смотреть на пути и не кэшировать всё подряд через CDN. Папку /upload/ — кэшируем смело, там пользовательские файлы. Папку /bitrix/cache/ — осторожно, там может быть динамический контент.
Laravel. В Laravel статика обычно собирается через Vite (в новых проектах) или Webpack Mix. Vite по умолчанию добавляет хэш к именам файлов при продакшн-сборке — это идеально для долгого кэширования с immutable. Настраиваете Nginx так же, как я показал выше, и всё работает из коробки.
Если вам нужна помощь с настройкой кэширования на конкретном проекте — это как раз то, чем я занимаюсь в рамках поддержки Битрикс-сайтов и поддержки WordPress. Иногда правильная настройка кэша занимает пару часов, а эффект — как от полного редизайна.
Стратегия кэширования для разных типов контента
Нет единого правила "кэшировать всё на год". Разный контент требует разного подхода. Вот моя рабочая стратегия:
- Изображения, шрифты, иконки —
Cache-Control: public, max-age=31536000, immutable. Год. С cache busting через хэш в имени. - CSS и JS — то же самое, но только если используете cache busting. Без него — максимум
max-age=86400(сутки) и никакогоimmutable. - HTML-страницы —
Cache-Control: public, max-age=0, stale-while-revalidate=86400. Илиno-cache. HTML должен быть свежим. - API-ответы — зависит от содержимого. Публичные данные, которые редко меняются — можно кэшировать на несколько минут. Персонализированные данные —
private, no-cacheилиno-store. - Видеофайлы —
public, max-age=2592000(месяц). Видео не меняется часто, но и год — перебор. - Файлы sitemap и robots.txt —
max-age=3600(час). Они могут обновляться при добавлении контента.
По опыту, самая распространённая ситуация — клиент приходит с проблемой "сайт медленно грузится", а оказывается, что у них картинки без Cache-Control вообще, каждый запрос идёт до сервера. Добавляем правильные заголовки, подключаем CDN — и сайт преображается. Это не магия, это просто правильная настройка того, что уже должно было работать.
Если хотите разобраться глубже с производительностью сайта — рекомендую посмотреть на комплексную доработку сайта. Кэширование статики — важная часть, но не единственная. Скорость сервера, оптимизация базы данных, правильная работа с изображениями — всё это работает в связке.
Хотите ускорить загрузку вашего сайта?
Настроим кэширование статики, подключим CDN и оптимизируем HTTP-заголовки, чтобы ваш сайт загружался максимально быстро.