Всем привет!
Меня зовут Прокошкин Леонид, я 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 живет отдельно и ничего не знает о бизнес логике приложения. Чтобы связать его с клиентской частью, нужно:

  1. Зарегистрировать воркер через navigator.serviceWorker.register.

  2. Дождаться готовности (navigator.serviceWorker.ready).

  3. Организовать удобный слой взаимодействия, я сделал это через хук

Перейдем к реализации

Константы окружения и маршруты

 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. Это ощутимо ускорило повторную загрузку стикеров, мы избавились от жесткого расхода трафика, убрали лишнюю нагрузку на сервер, стикеры теперь крутятся даже оффлайн, пользователи рады.