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