Кеширование Lottie-анимаций с помощью Service Worker

Всем привет! Меня зовут Прокошкин Леонид, я 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.

Теперь нам необходимо обработать события SW
install: установка воркера
Событие install запускается, когда браузер скачал новую версию SW и пытается ее установить.
— self.skipWaiting() пропускает стадию waiting — новая версия сразу перейдет к событию activate.
— event.waitUntil(caches.open(CACHE_NAME)) мы говорим что событие install будет ждать открытия кеша и перейдет к activate после успешного открытия, нам это необходимо для того чтобы к моменту активации мы точно знали что кеш есть, а так же быстро активируем обновления SW, если они есть.
Важно учитывать! skipWaiting() опасен при несовместимости активов: новая логика может обслуживать старые вкладки. Предкеш критичных файлов лучше тоже делать внутри waitUntil.

activate: захват клиентов
— Событие activate приходит сразу после успешного события install.
— clients.claim() — новый SW берет контроль над всеми открытыми вкладками (без их перезагрузки).
— «Миграция» кешей: удаляем все кеши, имена которых не равны CACHE_NAME. (это актуально если мы используем версионность кешей, в данном примере у нас этого нет)
— Настраиваем ttlMs (Time to Live ms) через Storage API: чем больше квота, тем дольше храним.
Важно учитывать! clients.claim() + skipWaiting() = быстрые апдейты, но думайте о совместимости со старой страницей. navigator.storage.estimate() может вернуть undefined — нужно писать дефолтный estimate, но в рамках разумного.

cleanupCacheIfNeeded: «ленивая» (пороговая) очистка по ttlMs
Проверяем, прошло ли ttlMs с момента последней чистки (lastCleanup). Если нет — выходим быстро. Если пора чистить: обходим все ключи кеша и удаляем записи, у которых метка ts старше ttlMs.
Почему ?ts= в ключе:
— Ключ кэша — это URL. Добавляя ?ts=..., фиксируем момент записи.
— При чтении используем ignoreSearch: true, но для очистки извлекаем ts из ключа.

fetch: перехват запросов
Обрабатываем только GET-запросы к файлам стикеров и пакету.
Cache First: сначала ищем в кеше (ignoreSearch: true, чтобы игнорировать наши query параметры ?ts..), если нет — идем в сеть, сохраняем копию с ?ts.
— При оффлайне возвращаем кеш (если есть), иначе — минимальный ответ 504.
Важно учитывать! ignoreSearch: true важно, потому что ключи содержат ?ts= Для UX вместо 504 можно, например, вернуть плейсхолдер анимацию или обработать по Вашему усмотрению

2) Хук для использования Service Worker
Сам по себе Service Worker живет отдельно и ничего не знает о бизнес логике приложения. Чтобы связать его с клиентской частью, нужно:
- Зарегистрировать воркер через navigator.serviceWorker.register.
- Дождаться готовности (navigator.serviceWorker.ready).
- Организовать удобный слой взаимодействия, я сделал это через хук.
Перейдем к реализации
Константы окружения и маршруты

Регистрация SW и базовая подготовка кеша
Регистрируем SW, ждем, когда сработает событие ready и открываем кеш.

Чтение пакета из кеша
Пытаемся найти пакет по ключу GET_STICKER_ROUTE (ignoreSearch: true, не забываем). Если нашли — парсим JSON. Поврежденные записи безопасно игнорируем.

Первый запуск: загрузка пакета
Если пакета нет в кеше — тянем из сети (SW положит ответ в кеш), затем заранее скачиваем файлы .lottie и сохраняем сам пакет с ?ts= для участия в TTL очистке.

Проверка и применение обновлений
Тут мы обращаемся к нашему API, если есть обновленные стикеры, то удаляем старые версии из кеша и записываем новые, делаем это точечно и сохраняем только обновленный стикер.

Итог
В итоге мы свели загрузку Lottie-анимаций к одному обращению в сеть и дальнейшей выдаче из Cache Storage. Это ощутимо ускорило повторную загрузку стикеров, мы избавились от жесткого расхода трафика, убрали лишнюю нагрузку на сервер, стикеры теперь крутятся даже оффлайн, пользователи рады.