Курьерскому приложению нужна собственная кладовая под ответы сети - место, которое полностью контролирует worker и содержимое которого переживает перезагрузку и разрыв связи. Ею служит Cache Storage, он же Cache API. Соблазн считать его просто ещё одним слоем браузерного кеша велик, но именно это заблуждение приводит к самым запутанным багам со свежестью.
По устройству это хранилище пар запрос-ответ, которым управляют из JavaScript. Вызов caches.open(name) открывает - или создаёт - именованный контейнер. У контейнера четыре основных операции: match ищет в нём подходящий Response по Request, put сохраняет пару запрос-ответ, delete удаляет запись, keys перечисляет сохранённые в нём запросы. Никакой автоматики здесь нет: запись появляется и исчезает только тогда, когда об этом явно попросил ваш код.
Отсюда главное отличие от HTTP-кеша браузера. HTTP-кеш подчиняется заголовкам Cache-Control и работает под капотом сам. Cache Storage не наследует вашу политику свежести - он хранит ровно то, что вы в него положили, и пока вы это не удалите. Заголовок Cache-Control: no-store, пришедший позже, сам по себе не сотрёт запись, которую вы явно сохранили. Более того, HTTP-кеш продолжает существовать ниже: когда worker идёт в сеть через fetch(), ответ может прийти уже из него. Два кеша живут на разных уровнях, и путать их - значит терять контроль над тем, какую версию видит пользователь.
Второй источник сюрпризов - ключ кеша. Совпадение ищется по URL и методу, а заголовок Vary в ответе может сузить его до конкретных заголовков запроса. Проблема в авторизованных данных: два GET на один URL - список заказов - вернут разное в зависимости от cookie или токена, хотя адрес совпадает. Сложив такой ответ в общий кеш без привязки к пользователю, вы отдадите следующему вошедшему чужие заказы. Поэтому личные ответы либо не кешируют вовсе, либо кладут в namespace, привязанный к аккаунту, и вычищают при выходе.
На этом фундаменте строится простейшая стратегия - Cache First. Логика прямая: сначала заглянуть в кеш, и если ответ там есть, немедленно вернуть его, не трогая сеть; только при промахе сходить в сеть и заодно сохранить ответ на будущее. Идеальный кандидат - неизменяемый ресурс с хешем содержимого в имени, например app.83fd1.js. Раз содержимое привязано к имени, устаревания не бывает: меняется содержимое - меняется URL.
Выгода очевидна: минимальная задержка, работа без сети, экономия трафика - ответ отдаётся из локального хранилища за миллисекунды. Но у стратегии есть цена, и платят её свежестью. Без версионирования URL, без ограничения по возрасту и без вытеснения пользователь может видеть один и тот же старый ресурс сколь угодно долго - кеш ведь не устаревает сам. Худший кандидат для Cache First - изменяемый HTML без срока жизни: положив его в кеш один раз, вы рискуете навсегда запереть человека в старой версии приложения.
Отсюда оговорки. Для картинок каталога, которые тоже неплохо ложатся в Cache First, добавляют лимит на число и возраст записей, иначе хранилище будет расти бесконтрольно. Для собранных с хешем бандлов надёжнее не полагаться на случайное заполнение в рантайме, а сложить их в кеш заранее, на этапе install - тогда оболочка гарантированно окажется на месте к первому офлайну. Cache First - выбор в пользу скорости там, где содержимое неизменяемо или его устаревание не вредит; иначе нужна стратегия, которая помнит про сеть.
async function safePut(cache, request, response) {
if (!response || !response.ok) return;
if (response.type === 'opaque') return; // если нет отдельной политики
const cc = response.headers.get('cache-control') ?? '';
if (/\bno-store\b/i.test(cc)) return;
await cache.put(request, response.clone());
}async function cacheFirst(request, cacheName) {
const cache = await caches.open(cacheName);
const hit = await cache.match(request);
if (hit) return hit;
const response = await fetch(request);
if (response.ok) await cache.put(request, response.clone());
return response;
}