Не всё в приложении заказов терпит устаревание. Статус заказа, остаток на складе, только что назначенный адрес - здесь пользователю нужна свежая версия, а старый снимок из кеша в лучшем случае бесполезен. Но сеть на слабом канале коварна: она не всегда честно отказывает, иногда она просто висит. Стратегия должна ставить свежесть на первое место и при этом не заставлять человека ждать зависший ответ бесконечно.
Ответ на это - Network First. Порядок обратный кешу-первым: сначала запрос идёт в сеть, и при успехе его результат и возвращают пользователю, и заодно кладут в кеш как последний удачный снимок. Только если сеть отказала, в дело вступает кеш - отдаётся тот самый последний снимок, а если и его нет, приложение синтезирует явный офлайн-ответ, например JSON со статусом 503 и признаком offline, чтобы UI показал честную заглушку вместо белого экрана.
Тонкость в слове отказала. Наивный таймаут через Promise.race просто перестаёт ждать сеть, но сам исходный fetch при этом не отменяется и продолжает висеть в фоне. Если сеть нужно действительно прервать, применяют AbortController. Но у отмены есть оборотная сторона: прервав запрос, вы лишаетесь возможности обновить кеш поздним, но успешным ответом, который пришёл бы через секунду после таймаута. Это осознанный компромисс - что важнее в конкретном случае: быстро показать кеш или всё же дождаться и сохранить свежее.
Для навигации по страницам у Network First есть штатный ускоритель - Navigation Preload. Обычно worker сначала должен проснуться, и только потом он инициирует fetch, и это пробуждение добавляет задержку. Navigation Preload запускает сетевой запрос параллельно с пробуждением worker, так что к моменту, когда обработчик готов, ответ уже в пути. Для медленного старта это заметная экономия.
Есть и промежуточная стратегия, снимающая конфликт между скоростью и свежестью, - Stale While Revalidate. Она отдаёт кешированный ответ мгновенно, а параллельно, в фоне, всё равно идёт в сеть и обновляет кеш к следующему разу. Пользователь получает моментальный отклик, а приложение - постепенно свежеющие данные. Плата за это - интерфейс должен понимать, что показывает снимок, возможно, слегка устаревший, и не выдавать его за последнюю истину.
В коде важна одна деталь: фоновое обновление нельзя бросать на произвол. Его оборачивают в event.waitUntil(), иначе браузер вправе усыпить worker сразу после того, как тот вернул кешированный ответ, и обновление не успеет завершиться. Сам ответ при этом отдаётся немедленно из кеша, а если кеша ещё нет - дожидаются сети как обычно.
Область применения SWR очерчена природой данных. Аватары, каталог, справочный контент - здесь мгновенный, пусть и чуть устаревший ответ явно лучше ожидания. А вот для баланса счёта, цены, прав доступа или результата платежа чуть старое значение хуже честной ошибки: показать неоплаченный заказ оплаченным опаснее, чем показать спиннер. И ещё одно: если фон обновил действительно значимые данные, недостаточно молча переписать кеш - об этом надо сообщить открытым вкладкам через postMessage, чтобы приложение корректно обновило экран, а не оставило пользователя смотреть на устаревшие цифры, которые в хранилище уже другие.
async function networkFirst(request, cacheName, timeoutMs = 2500) {
const cache = await caches.open(cacheName);
const timeout = new Promise((_, reject) =>
setTimeout(() => reject(new Error('timeout')), timeoutMs)
);
try {
const response = await Promise.race([fetch(request), timeout]);
if (response.ok) await cache.put(request, response.clone());
return response;
} catch {
return (await cache.match(request)) ??
new Response(JSON.stringify({ offline: true }), {
status: 503, headers: { 'content-type': 'application/json' }
});
}
}async function staleWhileRevalidate(event, cacheName) {
const cache = await caches.open(cacheName);
const cached = await cache.match(event.request);
const update = fetch(event.request).then(async response => {
if (response.ok) await cache.put(event.request, response.clone());
return response;
});
event.waitUntil(update.catch(() => undefined));
return cached ?? update;
}