Почему ваше HTTP-кэширование ломает PWA и как это исправить

Вы настроили Cache-Control для статики, добавили Service Worker и запустили Lighthouse. Все зелёное. Через неделю пользователи жалуются: "Приложение не обновляется!". Виновник — незаметный конфликт между HTTP-кэшем браузера и Service Worker. Разберёмся, как они взаимодействуют, где кроются подводные камни и как проектировать систему кэширования, которая не стреляет в ногу.

Анатомия проблемы: Двойное кэширование

Когда браузер загружает ресурс:

  1. Сначала проверяется HTTP-кэш (память/диск).
  2. Затем запрос перехватывает Service Worker (если зарегистрирован).
  3. Если SW решает запросить ресурс с сети — браузер снова проверит HTTP-кэш!

Пример типичного конфликта:

nginx
# Конфиг Nginx для статики  
location /static {  
  add_header Cache-Control "public, max-age=31536000, immutable";  
}  
javascript
// 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.

nginx
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:

javascript
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

Для данных, где важна актуальность, но допустимо краткое использование устаревшего контента:

javascript
// В 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;  
    });  
  })  
);  

Отладка: Где искать ошибки

  1. Chrome DevTools → Application → Cache Storage — проверьте, что SW кэширует ожидаемые версии файлов.
  2. Network tab — для статики статус (from disk cache) означает HTTP-кэш, (ServiceWorker) — кэш SW.
  3. 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, а главное — имитируйте поведение обновления при каждом релизе. Серый головной боли — первый признак того, что вы на верном пути.