
Всем привет!
Меня зовут Прокошкин Леонид, я Frontend-разработчик в компании DDPlanet.
Хочу рассказать о том, как мы решали проблему с высокой нагрузкой на сервер и большими расходами трафика при использовании тяжелых Lottie-анимаций.
В проекте было около 30 Lottie-анимаций, некоторые весили ~100 KB, и нам нужно было оптимизировать их загрузку.
Как снизить трафик и нагрузку? Правильно - кеширование. Мы выбрали кеширование на стороне Service Worker.
Сравнение скорости загрузки
Чтобы наглядно показать разницу в скорости загрузки, сравним время получения одной из анимации из сети и из кеша:
Загрузка из сети (~1300 ms):

Загрузка из кеша (~1.5 ms):

Думаю, разница очевидна. Итак, перейдем к реализации.
Почему именно Service Worker?
Service Worker - это фоновый скрипт, который работает отдельно от основного потока браузера. Его ключевые возможности:
Перехват сетевых запросов: можно решать, отдавать ли ответ из сети, из кеша или возвращать кастомный ответ.
Гибкое кеширование: сохранять файлы в Cache Storage, управлять временем жизни и условиями обновления.
Работа в оффлайне: даже если интернет недоступен, можно возвращать данные из кеша.
Фоновая обработка: например, проверка обновлений, синхронизация или очистка устаревших данных.
Теперь разберемся, почему не подходят другие популярные варианты:
Почему не localStorage
API синхронный - любое чтение/запись блокирует основной поток JS. Для JSON-анимаций по 100 KB это вызывает лаги.
Очень маленький лимит хранилища (обычно 5–10 MB).
Нет встроенного механизма версионирования - ключи и очистку придется писать вручную.
Почему не IndexedDB
API асинхронный и довольно сложный: транзакции, версии схем, обработка ошибок.
Отлично подходит для структурированных данных, но не ведет себя как HTTP-кеш для файлов.
Чтобы связать IndexedDB с fetch, нужны обертки и собственные механизмы TTL и версионирования.
Почему именно Service Worker + Cache Storage
Запрос остается обычным fetch, а SW решает - вернуть ответ из кеша или сети. (не нужно переписывать готовые запросы, чтобы внедрить SW).
Хранение оптимизировано браузером под бинарные файлы и JSON.
Time To Live и версионирование удобно реализовать через query-параметры (?ts=...).
Не блокирует основной поток: все выполняется в фоне.
Реализация
1) Service Worker
Создадим файл service-worker.js.
// service-worker.js const CACHE_NAME = 'stickers-lottie-cache'; // Маршрут для загрузки одного стикера по guid const STICKER_FILE_REGEX = /\/file\/download\/api\/Sticker\/Get\/[0-9a-fA-F-]+$/; // Маршрут для пакета (такой же, как и на клиенте) const STICKER_PACK_ROUTE = '/api/StickerPack/GetWelcomeStickerPack'; // Переменные для "ленивой" очистки let lastCleanup = 0; let ttlMs = 15 * 60 * 1000; // базовый TimeToLive (15 мин, пишем подходящий вам)
Теперь нам необходимо обработать события SW
install: установка воркера
Событие install запускается, когда браузер скачал новую версию SW и пытается ее установить.
self.skipWaiting() пропускает стадию waiting - новая версия сразу перейдет к событию activate.
event.waitUntil(caches.open(CACHE_NAME)) мы говорим что событие install будет ждать открытия кеша и перейдет к activate после успешного открытия, нам это необходимо для того, чтобы к моменту активации мы точно знали, что кеш есть, а так же быстро активируем обновления SW, если они есть.
Важно учитывать!
skipWaiting() опасен при несовместимости активов: новая логика может обслуживать старые вкладки.
Предкеш критичных файлов лучше тоже делать внутри waitUntil.
self.addEventListener('install', (event) => { self.skipWaiting() event.waitUntil(caches.open(CACHE_NAME)) })
activate: захват клиентов
Событие activate приходит сразу после успешного события install.
clients.claim() - новый SW берет контроль над всеми открытыми вкладками (без их перезагрузки).
«Миграция» кешей: удаляем все кеши, имена которых не равны CACHE_NAME.
(это актуально, если мы используем версионность кешей, в данном примере у нас этого нет)Настраиваем ttlMs (Time to Live ms) через Storage API: чем больше квота, тем дольше храним.
Важно учитывать!
clients.claim() + skipWaiting() = быстрые апдейты, но думайте о совместимости со старой страницей.
navigator.storage.estimate() может вернуть undefined - нужно писать дефолтный estimate, но в рамках разумного.
self.addEventListener('activate', (event) => { event.waitUntil( (async () => { await self.clients.claim() //На всякий случай удаляем старые версии кеша (если у нас была бы версионность, в данном примере ее нет) const keys = await caches.keys() await Promise.all(keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k))) // Адаптивный TTL в зависимости от доступной квоты try { const estimate = await navigator.storage?.estimate?.() const quota = estimate?.quota || 0 ttlMs = quota > 2 * 1024 ** 3 ? 60 * 60 * 1000 // >2 GiB → 60 мин : quota > 1 * 1024 ** 3 ? 30 * 60 * 1000 // >1 GiB → 30 мин : 15 * 60 * 1000 // иначе → 15 мин } catch { // оставляем ttlMs по умолчанию } })() ) })
cleanupCacheIfNeeded: «ленивая» (пороговая) очистка по ttlMs
Проверяем, прошло ли ttlMs с момента последней чистки (lastCleanup). Если нет - выходим быстро.
Если пора чистить: обходим все ключи кеша и удаляем записи, у которых метка ts старше ttlMs.
Почему ?ts= в ключе:
Ключ кэша - это URL. Добавляя ?ts=..., фиксируем момент записи.
При чтении используем ignoreSearch: true, но для очистки извлекаем ts из ключа.
const cleanupCacheIfNeeded = async () => { const now = Date.now() if (now - lastCleanup < ttlMs) return lastCleanup = now const cache = await caches.open(CACHE_NAME) const requests = await cache.keys() await Promise.all( requests.map((req) => { const url = new URL(req.url) const ts = Number(url.searchParams.get('ts')) if (ts && now - ts > ttlMs) { return cache.delete(req) } return Promise.resolve() }) ) }
fetch: перехват запросов
Обрабатываем только GET-запросы к файлам стикеров и пакету.
Cache First: сначала ищем в кеше (ignoreSearch: true, чтобы игнорировать наши query параметры ?ts..), если нет - идем в сеть, сохраняем копию с ?ts.
При оффлайне возвращаем кеш (если есть), иначе - минимальный ответ 504.
Важно учитывать!
ignoreSearch: true важно, потому что ключи содержат ?ts=
Для UX вместо 504 можно, например, вернуть базовую анимацию или обработать по Вашему усмотрению
self.addEventListener('fetch', (event) => { const { request } = event if (request.method !== 'GET') return const url = new URL(request.url) const isStickerFile = STICKER_FILE_REGEX.test(url.pathname) const isStickerPack = url.pathname.includes(STICKER_PACK_ROUTE) if (!isStickerFile && !isStickerPack) return event.respondWith( (async () => { await cleanupCacheIfNeeded() // Ищем в кеше без учета query (?ts=...) const cache = await caches.open(CACHE_NAME) const cached = await cache.match(request, { ignoreSearch: true }) if (cached) return cached try { const networkResponse = await fetch(request) const clone = networkResponse.clone() const tsKey = `${request.url}${request.url.includes('?') ? '&' : '?'}ts=${Date.now()}` cache.put(tsKey, clone).catch(() => {}) return networkResponse } catch { if (cached) return cached return new Response('Offline and not cached', { status: 504, statusText: 'Gateway Timeout', }) } })() ) })
2) Хук для использования Service Worker
Сам по себе Service Worker живет отдельно и ничего не знает о бизнес логике приложения. Чтобы связать его с клиентской частью, нужно:
Зарегистрировать воркер через navigator.serviceWorker.register.
Дождаться готовности (navigator.serviceWorker.ready).
Организовать удобный слой взаимодействия, я сделал это через хук
Перейдем к реализации
Константы окружения и маршруты
const CACHE_NAME = 'stickers-lottie-cache' //имя кеша (совпадает с именем в SW) const STICKER_PACK_ROUTE = '/api/StickerPack/GetWelcomeStickerPack' //API роут для пакета // SSR-защита const ORIGIN = typeof window !== 'undefined' ? window.location.origin : '' //SSR защита (вне браузера window нет), const GET_STICKER_ROUTE = `${ORIGIN}${STICKER_PACK_ROUTE}` //абсолютный URL ключа в кеше // Типы данных type StickerUrl = { dotLottieFormatUrl?: string } type Sticker = { id: string; urls: StickerUrl } type StickerPack = { id: string; updateDate: string; stickers: Sticker[] } // API-заглушки declare function getWelcomeStickerPackAPI(): Promise<{ result: StickerPack }> declare function getStickerPackUpdatesAPI(args: { stickerPackId: string; lastUpdateDate: string; }): Promise<Partial<StickerPack> declare function downloadFileAPI(url: string): Promise<void>
Регистрация SW и базовая подготовка кеша
Регистрируем SW, ждем, когда сработает событие ready и открываем кеш.
export const registerServiceWorker = async () => { await navigator.serviceWorker.register('/service-worker.js') await navigator.serviceWorker.ready const cache = await caches.open(CACHE_NAME) // ... }
Чтение пакета из кеша
Пытаемся найти пакет по ключу GET_STICKER_ROUTE (ignoreSearch: true, не забываем). Если нашли - парсим JSON. Поврежденные записи безопасно игнорируем.
let cachedPack: StickerPack | null = null const cache = await caches.open(CACHE_NAME) const cachedResp = await cache.match(GET_STICKER_ROUTE, { ignoreSearch: true }) if (cachedResp) { try { cachedPack = await cachedResp.json() } catch { cachedPack = null } }
Первый запуск: загрузка пакета
Если пакета нет в кеше - тянем из сети (SW положит ответ в кеш), затем заранее скачиваем файлы .lottie и сохраняем сам пакет с ?ts= для участия в TTL очистке.
if (!cachedPack) { const pack = await getWelcomeStickerPackAPI() cachedPack = pack.result await Promise.all( cachedPack.stickers.map(({ urls }) => urls.dotLottieFormatUrl ? downloadFileAPI(urls.dotLottieFormatUrl) : Promise.resolve() ) ) await cache.delete(GET_STICKER_ROUTE, { ignoreSearch: true }).catch(() => {}) await cache.put( `${GET_STICKER_ROUTE}?ts=${Date.now()}`, new Response(JSON.stringify(cachedPack), { headers: { 'Content-Type': 'application/json' } }) ) }
Проверка и применение обновлений
Тут мы обращаемся к нашему API, если есть обновленные стикеры, то удаляем старые версии из кеша и записываем новые, делаем это точечно и сохраняем только обновленный стикер
const updates = await getStickerPackUpdatesAPI({ stickerPackId: cachedPack.id, lastUpdateDate: cachedPack.updateDate, }) if (!!updates?.stickers?.length) { for (const updatedSticker of updates.stickers) { const prev = cachedPack.stickers.find((s) => s.id === updatedSticker.id) const oldUrl = prev?.urls.dotLottieFormatUrl if (oldUrl) { await cache.delete(oldUrl, { ignoreSearch: true }).catch(() => {}) } if (updatedSticker.urls.dotLottieFormatUrl) { await downloadFileAPI(`${updatedSticker.urls.dotLottieFormatUrl}?force=true`) } } const nextStickersMap = new Map() for (const s of cachedPack.stickers) nextStickersMap.set(s.id, s) for (const us of updates.stickers) nextStickersMap.set(us.id, { ...nextStickersMap.get(us.id), ...us }) const nextPack: StickerPack = { ...cachedPack, ...updates, stикkers: Array.from(nextStickersMap.values()), updateDate: updates.updateDate ?? new Date().toISOString(), } await cache.delete(GET_STICKER_ROUTE, { ignoreSearch: true }).catch(() => {}) await cache.put( `${GET_STICKER_ROUTE}?ts=${Date.now()}`, new Response(JSON.stringify(nextPack), { headers: { 'Content-Type': 'application/json' } }) ) }
В итоге мы свели загрузку Lottie-анимаций к одному обращению в сеть и дальнейшей выдаче из Cache Storage. Это ощутимо ускорило повторную загрузку стикеров, мы избавились от жесткого расхода трафика, убрали лишнюю нагрузку на сервер, стикеры теперь крутятся даже оффлайн, пользователи рады.
