
Всем привет!
Меня зовут Прокошкин Леонид, я 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. Это ощутимо ускорило повторную загрузку стикеров, мы избавились от жесткого расхода трафика, убрали лишнюю нагрузку на сервер, стикеры теперь крутятся даже оффлайн, пользователи рады.
Комментарии (7)
KivApple
02.10.2025 12:03Почему нельзя просто отдать Cache-Control со сроком годности в год? Чтобы обновлять анимации настроить бандлер добавлять хеши к именам файлов, тогда новая версия новое имя файла, обход кеша. Обычно это поведение по умолчанию, просто надо не класть анимации в public каталог, а импортировать в JS их пути/содержимое (бандлеры отлично умеют импорт JSON, в том числе асинхронный), чтобы они прошли бандлер.
Либо честная обработка If-Not-Modified-Since заголовка сервером + отдача Last-Modified заголовка. Либо Etag + If-None-Matches по вкусу. Опять же, большинство веб-серверов делают это по умолчанию при отдаче статики. Если не делают, надо чинить, там обычно вопрос одной опции, причём часто идущей по умолчанию.
В браузерах, http протоколе и веб серверах есть всё необходимое для кеширования, если это специально не ломать кривыми настройками.
А Service Worker тут стрельба из пушки по воробям, он нужен для разработки приложений, которые вообще без интернета работают.
Статья из разряда "как не надо делать".
domix32
Звучит подозрительно сложно. А нельзя сказать в ответе на запрос к анимации, чтобы браузер просто закэшировал этот ресурс стандартным образом? Картинки же сайты как-то кэшируют, что мешает проворачивать ровно то же самое с Lottie? И перед запросом самой картинки какой-нибудь OPTION слать для инвалидации кэша.
forjke Автор
Здравствуйте! Да, действительно, в большинстве случаев можно было бы использовать дефолтное браузерное кеширование, но в нашем случае необходимо было поддерживать актуальность анимаций у всех пользователей в один момент времени (не считая офлайн), а также точечно их обновлять в случае, если они изменились.
domix32
Не очень понятно что подразумевается под "поддерживать актуальность анимаций". У вас там стриминг анимаций происходит? Дык Lottie вроде так и не умеет. Ну а для гарантированного обновления собственно есть тот же Option и возможность слать ответ 304 Not Modified. Настраиваете Cache-Control в заголовках и погнал.
KivApple
If-Not-Modified-Since + Last-Modified или If-None-Matches + Etag. Почему ваш сервер не обрабатывает/отдаёт эти заголовки на статику (это поведение по умолчанию любого нормального веб-сервера и даже модуля отдачи статики dev-сервера)?
Почему ваш бандлер не добавляет хеши ко всем ресурсам проекта, чтобы при любом изменении получалось уникальное имя (и тогда не важно сколько браузер будет кешировать старую версию)?
Тут определённо что-то не так!
forjke Автор
"Почему ваш бандлер не добавляет хеши ко всем ресурсам проекта, чтобы при любом изменении получалось уникальное имя (и тогда не важно сколько браузер будет кешировать старую версию)? "
Здравствуйте!
Бандлер добавляет хеш к каждому ресурсу (css, html, js), но в данном случае анимации хранятся в бд, анимации не являются ресурсами проекта
"If-Not-Modified-Since + Last-Modified или If-None-Matches + Etag. Почему ваш сервер не обрабатывает/отдаёт эти заголовки на статику (это поведение по умолчанию любого нормального веб-сервера и даже модуля отдачи статики dev-сервера) "
Этот подход не давал уверенности что браузер сбросит/обновит кеш у каждого пользователя в один момент времени (например, при апдейте одной анимации из пака)
KivApple
Спасибо за ответ
Были на практике случаи, когда браузер не слал If-Not-Modified-Since/If-None-Matches при наличии ресурса в кеше (причём кеш по etag даже не чувствителен к сбитым часам клиента, так как всё идёт по хешу, а не дате изменения)? Интересно