Вы настроили Cache-Control
для статики, добавили Service Worker и запустили Lighthouse. Все зелёное. Через неделю пользователи жалуются: "Приложение не обновляется!". Виновник — незаметный конфликт между HTTP-кэшем браузера и Service Worker. Разберёмся, как они взаимодействуют, где кроются подводные камни и как проектировать систему кэширования, которая не стреляет в ногу.
Анатомия проблемы: Двойное кэширование
Когда браузер загружает ресурс:
- Сначала проверяется HTTP-кэш (память/диск).
- Затем запрос перехватывает Service Worker (если зарегистрирован).
- Если SW решает запросить ресурс с сети — браузер снова проверит HTTP-кэш!
Пример типичного конфликта:
# Конфиг Nginx для статики
location /static {
add_header Cache-Control "public, max-age=31536000, immutable";
}
// Service Worker: стратегия Cache-First
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request)
.then(cached => cached || fetch(event.request))
);
});
Кажется, всё логично: статика кэшируется на год, SW дублирует кэш. Но при обновлении main.js
:
- Браузер использует свой HTTP-кэш (старая версия) → SW получает устаревший ресурс → кладёт его в свой кэш → замкнутый круг.
Итог: Пользователи месяцами видят старую версию приложения.
Решение 1: Точечный контроль над HTTP-кэшем
Правило: Для HTML и манифеста — запрещайте кэширование браузером. Для статики — длинный max-age
с immutable
.
location / {
add_header Cache-Control "no-cache, no-store, must-revalidate"; # HTML, API
}
location /static {
add_header Cache-Control "public, max-age=31536000, immutable";
}
Почему "immutable"? Браузер не будет проверять обновления файла при повторных запросах. Экономит 300ms на каждом ресурсе. Работает в Chrome/Firefox/Safari.
Решение 2: Service Worker с versioning
Используйте хеши имён файлов для статики (Webpack: [contenthash]
). При обновлении — меняется URL, поэтому:
- Браузер загружает новый файл с сервера (старый остается в кэше, но не используется)
- SW кэширует новые URL автоматически
Добавим инвалидацию кэша в SW:
const CACHE_NAME = 'v2'; // Меняем при обновлении
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME)
.then(cache => cache.addAll([
'/styles.a64d1f98.css',
'/app.5e2f8c44.js'
]))
);
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then(keys =>
Promise.all(
keys.filter(key => key !== CACHE_NAME)
.map(key => caches.delete(key)) // Удаляем старые кэши
)
)
);
});
Решение 3: Сетевая синхронизация с Stale-While-Revalidate
Для данных, где важна актуальность, но допустимо краткое использование устаревшего контента:
// В Service Worker
event.respondWith(
caches.open('runtime-data').then(cache => {
return cache.match(event.request).then(cached => {
const fetchPromise = fetch(event.request).then(resp => {
cache.put(event.request, resp.clone()); // Обновляем кэш в фоне
return resp;
});
return cached || fetchPromise;
});
})
);
Отладка: Где искать ошибки
- Chrome DevTools → Application → Cache Storage — проверьте, что SW кэширует ожидаемые версии файлов.
- Network tab — для статики статус
(from disk cache)
означает HTTP-кэш,(ServiceWorker)
— кэш SW. - Lighthouse → PWA section — предупредит об отсутствии
no-cache
для HTML.
Рекомендации
- SW как единственный кэшировщик: Для динамических ресурсов (HTML, API) делегируйте кэширование только SW. Сервер должен отвечать
Cache-Control: no-store
. - Обновление “на лету”: Используйте
workbox-broadcast-update
для уведомления открытых вкладок о новой версии SW. - Не кэшируйте
/
: SW для главной страницы должен загружать HTML из сети (сno-cache
), если только это не офлайн-приложение.
Управление кэшированием — не “раздери и забудь”. Это баланс между перформансом и актуальностью. Настройте HTTP-заголовки для бессменной статики, перехватывайте динамические запросы через Service Worker, а главное — имитируйте поведение обновления при каждом релизе. Серый головной боли — первый признак того, что вы на верном пути.