Перейти к основному содержимому

2.04. Фоновые процессы и работа без интернета

Всем

Фоновые процессы и работа веб-приложений без интернета

Традиционная веб-страница представляет собой статический или динамически сгенерированный документ, жизненный цикл которого ограничен временем открытия вкладки до её закрытия. Выполнение JavaScript-кода полностью зависит от активности окна браузера: при сворачивании вкладки или переходе на другой сайт большинство задач приостанавливается, а при закрытии — полностью прекращается. Это соответствует изначальной парадигме World Wide Web: клиент делает запрос — сервер отвечает — клиент отображает результат. Связь однократна, состояние — эфемерно, автономность — невозможна.

Современные веб-приложения, однако, стремятся к уровню функциональности, привычному в нативных средах: работа в офлайне, фоновая загрузка, push-уведомления, фоновая синхронизация изменений, обновление содержимого без участия пользователя. Такие требования выходят за рамки классической клиент-серверной модели и требуют фундаментального пересмотра архитектуры выполнения кода на стороне клиента. Здесь ключевым становится понятие автономности, а техническим фундаментом — фоновые процессы.

Что такое автономность в контексте веб-приложений?

Автономность — это способность приложения функционировать при отсутствии стабильного или вообще любого сетевого соединения с сервером. Это не просто «открытие сохранённой копии страницы», а полноценное выполнение бизнес-логики: редактирование данных, навигация по интерфейсу, локальная валидация, реакция на действия пользователя — всё это должно происходить без задержек, вызванных ожиданием ответа от сервера.

Важно подчеркнуть: автономность не означает полную независимость от сервера. Она подразумевает временную декомпозицию взаимодействия. Приложение продолжает работать, накапливая изменения локально; в моменты, когда связь восстанавливается, оно автоматически или по инициативе пользователя согласует своё состояние с сервером. Таким образом, автономность — это не альтернатива онлайн-режиму, а его надстройка, обеспечивающая устойчивость к сетевым сбоям и повышающая воспринимаемую отзывчивость.

Степень автономности может варьироваться:

  • Частичная — доступна только часть функционала (например, чтение ранее загруженных статей, но не публикация комментариев).
  • Полная — все ключевые операции возможны локально, включая создание, редактирование и удаление сущностей; синхронизация происходит прозрачно для пользователя.

Достижение автономности требует комплексного подхода, затрагивающего хранилище, сетевую логику, управление состоянием и интерфейс. Основной механизм, делающий это возможным в современном вебе, — вынос части логики выполнения за пределы жизненного цикла страницы.

Фоновые процессы

Фоновый процесс — это задача, выполняемая браузером независимо от активности вкладки и даже после её закрытия. Это принципиальный сдвиг: JavaScript, изначально ограниченный sandbox’ом документа, получает возможность «жить» дольше самого документа.

Такая возможность не появилась внезапно. Её предпосылками стали:

  • Повсеместное распространение мобильных устройств с нестабильными и дорогими каналами связи.
  • Рост требований к UX: пользователи ожидают мгновенного отклика, как в нативных приложениях.
  • Ограничения push-уведомлений и фоновой загрузки в нативных веб-приложениях (PWA), которые требовали стандартизированных, безопасных и энергоэффективных решений.

Важно чётко разделять фоновую работу от псевдо-фоновых практик:

  • setTimeout/setInterval с большими задержками не являются фоновыми: они приостанавливаются при неактивности вкладки.
  • requestIdleCallback или visibilitychange позволяют отложить выполнение, но не гарантируют его после закрытия вкладки.
  • Web Workers работают в отдельном потоке, но их жизненный цикл всё равно привязан к странице-родителю.

Настоящая фоновая работа в вебе возможна только через Service Worker и API, построенные на его основе. Service Worker — это скрипт, зарегистрированный браузером как системный обработчик событий, связанных с сетью, временем, push-каналами и фоновыми задачами. Он запускается по событию (например, входящий push, таймер, необходимость синхронизации), выполняет необходимые действия и завершается. Такой подход минимизирует энергопотребление и предотвращает неограниченное выполнение произвольного кода.

Синхронизация

Синхронизация — центральное понятие в архитектуре автономных приложений. Она представляет собой процесс достижения согласованности данных между локальным клиентским состоянием и удалённым серверным состоянием.

Синхронизация не всегда подразумевает немедленный обмен. Её можно классифицировать по нескольким измерениям:

По направлению:

  • Однонаправленная (push/pull) — данные обновляются только с сервера на клиент (например, обновление новостей) или только с клиента на сервер (например, сохранение черновика).
  • Двунаправленная — изменения возможны с обеих сторон; требуется механизм разрешения конфликтов (например, два пользователя редактируют один документ).

По времени:

  • Мгновенная (real-time) — изменения передаются и применяются сразу (WebSockets, SSE). Требует постоянного соединения.
  • Отложенная (deferred) — действия ставятся в очередь и выполняются при удобном случае: при восстановлении сети, при следующем запуске приложения, в фоне. Именно отложенная синхронизация лежит в основе автономной работы.

По инициатору:

  • Пользовательская — синхронизация запускается по явному действию (кнопка «Сохранить и отправить»).
  • Системная — происходит автоматически, без участия пользователя, по расписанию или по событию (подключение к Wi-Fi, тихий период).

Важнейшее свойство устойчивой синхронизации — идемпотентность операций. Клиент должен иметь возможность повторно отправить одно и то же действие (например, «сохранить заказ №123»), не опасаясь дублирования на сервере. Это достигается через уникальные идентификаторы операций, версионирование сущностей или применение операций в виде diff-патчей.

Автономное приложение обязано вести учёт состояния синхронизации для каждой сущности: «сохранено локально», «ожидает отправки», «отправлено, ожидает подтверждения», «синхронизировано», «обнаружен конфликт». Такая метаинформация хранится вместе с данными и управляет поведением интерфейса (например, серый значок галочки превращается в зелёный после подтверждения сервером).

Service Worker и фоновые API

Service Worker

Service Worker — промежуточный прокси-обработчик, встроенный в стек сетевых вызовов браузера. После регистрации и активации он перехватывает все исходящие fetch-запросы от страницы, а также получает события от системных API: push, notificationclick, sync, backgroundfetchsuccess и др.

Важнейшие особенности Service Worker’а:

  • Работает в отдельном глобальном контексте, изолированном от window. Нет доступа к DOM, localStorage, синхронным XHR.
  • Запускается по событию, а не постоянно. После обработки события он «усыпляется», что экономит ресурсы.
  • Может быть активен, даже когда ни одна вкладка приложения не открыта — если есть ожидающие события (например, push-уведомление или отложенная синхронизация).
  • Имеет доступ к IndexedDB, Cache API, Notifications API, Push API, Background Sync API — то есть ко всем инструментам, необходимым для автономной работы.

Service Worker не делает приложение автономным сам по себе, он предоставляет платформу, на которой строятся решения: кэширование ресурсов, перехват запросов к API, постановка задач в очередь синхронизации, реакция на сетевые изменения. Реализация логики автономности — задача разработчика приложения.

Service Worker не является «демоном», постоянно висящим в памяти. Его поведение строго регламентировано событийной моделью и политиками энергосбережения. Понимание жизненного цикла критично для корректной реализации фоновых задач.

Регистрация происходит с помощью navigator.serviceWorker.register(). Браузер скачивает скрипт, проверяет его синтаксис и запускает в отдельном потоке. На этом этапе ещё нет перехвата сетевых запросов — Service Worker находится в состоянии installing.

Установка (install) — фаза, в которой приложение может подготовить начальное состояние: создать кэш, инициализировать базу данных, загрузить статические ресурсы. Обработчик события install должен вызвать event.waitUntil(), передав промис, завершение которого сигнализирует об успешной установке. Пока этот промис не разрешён, Service Worker остаётся в состоянии installing и не активируется. Это позволяет гарантировать, что все необходимые ресурсы доступны до начала перехвата запросов.

Активация (activate) происходит после завершения установки и после того, как все вкладки, использующие предыдущую версию Service Worker’а, будут закрыты (если не вызван skipWaiting()). На этапе активации обычно производится миграция данных — очистка устаревших кэшей, обновление схем IndexedDB. После активации Service Worker получает статус active и начинает перехватывать сетевые события.

Выполнение по событию — основной режим работы. Service Worker «просыпается» только при наступлении события:

  • fetch — исходящий сетевой запрос от страницы;
  • push — получение push-сообщения от сервера;
  • notificationclick — клик по уведомлению;
  • sync — событие фоновой синхронизации;
  • backgroundfetchsuccess / backgroundfetchfail — завершение фоновой загрузки.

После завершения обработчика события Service Worker, если у него нет ожидающих асинхронных задач (например, незавершённых промисов, зарегистрированных через event.waitUntil()), немедленно терминируется. Это гарантирует, что произвольный код не будет работать в фоне бесконтрольно.

Изоляция Service Worker’а выражается не только в отсутствии доступа к DOM. Он работает в своём собственном глобальном контексте (self), имеет отдельный event loop, не разделяет память с вкладками, и не может напрямую взаимодействовать с ними. Обмен данными возможен только через:

  • postMessage() — явная передача сообщений между страницей и Service Worker’ом;
  • косвенное взаимодействие через общие хранилища (IndexedDB, Cache API);
  • инициирование событий на странице (например, отправка уведомления, на которое пользователь может кликнуть).

Эта изоляция — не ограничение, а мера безопасности. Она предотвращает утечку сессий, кражу данных через вредоносные скрипты и несанкционированное потребление ресурсов.

Фоновая синхронизация (Background Sync)

Background Sync API решает задачу: «Как гарантировать, что действие пользователя будет выполнено, даже если он закрыл приложение до завершения отправки?»

Без этого API классическая реализация выглядит так: пользователь нажимает «Отправить», приложение делает fetch(), и если соединение нестабильно — запрос падает с ошибкой, интерфейс показывает «Не удалось отправить», и пользователь вынужден повторять действие. Это нарушает принцип «предварительного подтверждения» (optimistic UI), снижает доверие к приложению и ухудшает UX.

Background Sync позволяет сделать шаг вперёд: после нажатия «Отправить» приложение немедленно обновляет интерфейс (например, добавляет сообщение в чат с серой галочкой) и регистрирует задачу синхронизации. Даже если вкладка будет закрыта, браузер сохранит эту задачу и выполнит её в фоне, как только появится интернет.

Техническая последовательность:

  1. Приложение регистрирует sync-метку через navigator.serviceWorker.ready.then(reg => reg.sync.register('send-message')).
    Метка ('send-message') — это идентификатор типа задачи, который будет передан Service Worker’у в событии sync.
  2. Service Worker обрабатывает событие sync с соответствующей меткой. Внутри обработчика он извлекает данные из локального хранилища (например, из очереди сообщений в IndexedDB), отправляет их на сервер, и по результату обновляет статус в базе («отправлено»).
  3. Если отправка завершается неудачей (например, сервер недоступен), Service Worker не подтверждает обработку события — и браузер автоматически повторит попытку позже, с экспоненциальной задержкой.

Важно: Background Sync — это не push, а pull со стороны клиента. Сервер не инициирует синхронизацию; она запускается клиентом по событию (регистрация метки) и выполняется браузером по своему усмотрению, с учётом состояния сети, заряда батареи и политики энергосбережения.

Требования и ограничения

  • Доступен только в безопасном контексте (https или localhost).
  • Требует наличия активного Service Worker’а.
  • Не гарантирует момент выполнения — браузер может отложить синхронизацию на часы или даже дни, если устройство в режиме энергосбережения.
  • На мобильных устройствах (особенно iOS) реализация может быть ограничена: Safari поддерживает Background Sync только начиная с версии 16.4, и даже там поведение менее предсказуемо, чем в Chromium.
  • Каждая метка синхронизации уникальна в рамках одного Service Worker’а. Повторная регистрация той же метки не создаёт новую задачу, а обновляет существующую (если она ещё не выполнена).

Практическое использование

Рекомендуется использовать Background Sync не для каждой операции, а для группировки логически связанных действий. Например, вместо регистрации отдельной синхронизации на каждое сообщение в чате — накапливать сообщения в локальной очереди и запускать одну синхронизацию при первом добавлении или при выходе из приложения. Это снижает нагрузку на сеть и упрощает обработку ошибок.

Фоновое извлечение (Background Fetch)

Background Fetch API расширяет идею фоновой синхронизации, но в обратном направлении: не отправка данных на сервер, а массовая загрузка данных с сервера, даже когда приложение неактивно.

Он решает задачи:

  • Предзагрузка контента для офлайн-доступа (выпуски подкастов, эпизоды сериала, обновления справочников).
  • Загрузка больших файлов без риска прерывания при сворачивании приложения.
  • Обновление кэша в фоне без блокировки UI.

Принцип работы:

  1. Приложение инициирует фоновую загрузку через reg.backgroundFetch.fetch('podcast-ep123', [request1, request2], options).
    Можно передать массив запросов (например, аудиофайл + JSON-описание), а также параметры: отображать ли прогресс в уведомлении, можно ли использовать мобильный интернет.
  2. Браузер берёт управление на себя: он показывает системное уведомление с прогресс-баром, управляет повторными попытками при обрыве, учитывает политики трафика (например, не загружать по мобильной сети без разрешения).
  3. По завершении (успешном или нет) браузер запускает Service Worker и передаёт ему событие backgroundfetchsuccess или backgroundfetchfail.
  4. В обработчике Service Worker получает доступ к загруженным данным через event.fetches, проверяет их целостность, сохраняет в IndexedDB или Cache Storage, и может отправить пользователю итоговое уведомление («Эпизод готов к прослушиванию»).

Ключевое преимущество перед ручной реализацией через fetch + Service Worker — делегирование управления жизненным циклом загрузки браузеру. Разработчику не нужно:

  • вручную обрабатывать обрывы соединения,
  • управлять повторными запросами,
  • синхронизировать прогресс между вкладками,
  • беспокоиться о том, убит ли процесс при сворачивании.

Background Fetch — это «загрузка как сервис»: надёжная, наблюдаемая, энергоэффективная.

Ограничения

  • Поддерживается только в Chromium-браузерах (Chrome, Edge, Opera). Firefox и Safari — не реализованы.
  • Требует явного разрешения на показ уведомлений (поскольку прогресс отображается в них).
  • Объём загружаемых данных может быть ограничен браузером (обычно — до нескольких гигабайт, но конкретные лимиты зависят от устройства и ОС).
  • Нельзя получить прямой доступ к данным до обработки события в Service Worker’е — они хранятся во временной зоне, защищённой от прямого чтения страницей.

Безопасность и согласие пользователя

Фоновая работа расширяет возможности веб-платформы, но одновременно усиливает риски: несанкционированное потребление трафика, энергии, слежка за поведением пользователя. Поэтому все ключевые API работают только при соблюдении строгих условий:

  1. Безопасный контекст (HTTPS). Исключение — localhost для разработки.
  2. Явное согласие на уведомления. Push-уведомления и прогресс фоновой загрузки требуют вызова Notification.requestPermission(). Без разрешения — никаких фоновых событий, связанных с уведомлениями.
  3. Пользовательский жест. Регистрация Background Sync или Background Fetch должна происходить в контексте действия пользователя (клик, нажатие Enter). Автоматическая регистрация при загрузке страницы запрещена.
  4. Прозрачность. Браузеры обязаны предоставлять пользователю контроль: в настройках можно отключить фоновую синхронизацию, push-уведомления, фоновую загрузку для конкретного сайта.

Эти ограничения не являются «недостатками», а частью модели доверия веб-платформы. Они позволяют разработчикам строить мощные приложения, не жертвуя конфиденциальностью и контролем со стороны пользователя.


Стратегии управления данными и пользовательский опыт в автономных приложениях

Возвратный кэш (Stale-While-Revalidate)

Stale-While-Revalidate (SWR) — не только HTTP-директива (Cache-Control: stale-while-revalidate=<delta-seconds>), но и архитектурный паттерн, впервые популяризированный в библиотеке swr и адаптированный для Service Worker’ов.

Суть паттерна:
Показать устаревшие, но уже имеющиеся данные немедленно, параллельно инициировав фоновую проверку их актуальности.

Это радикально отличается от классических подходов:

  • Cache-First — возвращает кэш, но не обновляет его до следующего запроса (риск показа сильно устаревших данных).
  • Network-First — ждёт ответа от сервера, блокируя интерфейс при сетевых задержках или отсутствии связи.
  • Network-Only — игнорирует кэш полностью.

SWR обеспечивает немедленный отклик интерфейса при первом открытии (например, списка новостей), а через доли секунды — автоматическое обновление, если сервер вернул более свежие данные. При этом, если сеть недоступна, пользователь всё равно видит содержимое, а не пустой экран с индикатором загрузки.

В Service Worker’е паттерн реализуется следующим образом:

  1. При получении fetch-события проверяется наличие ресурса в кэше.
  2. Если есть — немедленно возвращается ответ из кэша (event.respondWith(cachedResponse)).
  3. Параллельно, в фоне (без ожидания через event.waitUntil()), запускается запрос к серверу.
  4. При получении свежего ответа кэш обновляется; если данные изменились — посылается сообщение в открытые вкладки (clients.claim() + postMessage()), чтобы интерфейс обновился без перезагрузки.

SWR особенно эффективен для:

  • часто читаемых, редко изменяемых ресурсов (статические страницы, справочники, профили);
  • данных, где допустима кратковременная неактуальность (новостные ленты, рейтинги);
  • инициализации UI при первом запуске приложения.

Ограничение: не подходит для операций, требующих строгой согласованности (например, проверка баланса перед оплатой). В таких случаях применяется гибридный подход: SWR для чтения, Network-First или Network-Only для записи.

Классификация стратегий кэширования

Помимо SWR, в автономных приложениях применяются следующие фундаментальные стратегии (в терминах Workbox, но применимые и вручную):

  • Cache-Only — возвращать только из кэша. Используется для критически важных статических ресурсов (HTML-оболочка, иконки, скрипты), гарантируя доступность приложения даже при полном отсутствии сети. Рискован для динамических данных.

  • Network-Only — игнорировать кэш, всегда обращаться к серверу. Применяется для операций, где актуальность обязательна (аутентификация, финансовые операции).

  • Cache-First (с fallback) — сначала кэш, при отсутствии — сеть. Основной паттерн для статики и медленно меняющихся данных. Часто дополняется фоновым обновлением кэша после ответа.

  • Network-First (с fallback) — сначала сеть, при ошибке — кэш. Подходит для динамических данных, где важна свежесть, но допустимо показать устаревшую версию при сбое (например, лента сообщений в чате).

  • Stale-While-Revalidate — как описано выше: отдача кэша + фоновый запрос.

Выбор стратегии зависит от семантики ресурса, а не от его типа. Один и тот же эндпоинт /api/user может обрабатываться по SWR при открытии профиля (пользователь увидит своё имя мгновенно), но по Network-First при попытке смены email (требуется свежее состояние сервера для валидации).

Управление состоянием

Автономность требует смены фокуса: вместо хранения данныххранение операций.

Классическая онлайн-модель:
UI → отправка запроса → обновление состояния после ответа.

Автономная модель:
UI → запись операции в локальную очередь → оптимистичное обновление UI → фоновая отправка операции → подтверждение/откат по результату.

Каждая операция должна содержать:

  • Уникальный идентификатор (UUID), чтобы избежать дублирования при повторных отправках;
  • Ссылку на сущность (например, postId: 'abc-123');
  • Тип действия (create, update, delete, patch);
  • Полезную нагрузку (новые значения полей, delta-изменения);
  • Метаданные: временная метка, версия сущности, идентификатор сессии.

Пример структуры операции:

{
"id": "op-9f3a2b1c",
"entity": "comment",
"entityId": "cmt-550e8400",
"action": "create",
"payload": {
"text": "Отличная статья!",
"authorId": "usr-abc",
"postId": "post-xyz"
},
"meta": {
"timestamp": 1732194800000,
"version": 1,
"sessionId": "sess-7d8f"
}
}

Очередь операций хранится в IndexedDB (обычно в отдельной таблице pendingOperations). Service Worker при событии sync извлекает операции, отправляет их на сервер в пакетах (для снижения количества запросов), и по получении подтверждения удаляет их из очереди. При ошибке — сохраняет с новой меткой повтора или помечает как конфликтную.

Такой подход обеспечивает:

  • Устойчивость к перезапуску — операции не теряются при закрытии вкладки;
  • Идемпотентность — сервер может обрабатывать одну операцию многократно без последствий;
  • Трассируемость — лог операций позволяет диагностировать сбои синхронизации.

Разрешение конфликтов

Конфликт возникает, когда локальное и удалённое состояния одной сущности расходятся в результате независимых изменений. Пример: пользователь редактирует пост в офлайне, а в это время модератор удаляет его на сервере.

Автономное приложение обязано иметь стратегию разрешения конфликтов. Она может быть:

  • Автоматической (по правилам);
  • Полуавтоматической (с участием пользователя);
  • Ручной (только через интерфейс разрешения).

Автоматические стратегии

  • Победа сервера — локальные изменения отменяются. Подходит для систем, где сервер — единственный источник истины (например, банковские операции).
  • Победа клиента — серверные изменения перезаписываются. Опасно, но допустимо для персональных черновиков.
  • Слияние по полям — для каждого поля выбирается значение по правилу: «самое новое», «не null», «по приоритету источника». Требует метаданных изменения (кто, когда, какое поле).
  • Векторные часы / CRDT — распределённые структуры данных, гарантирующие конвергенцию без центрального координатора. Применяются в продвинутых системах (например, совместные редакторы), но сложны в реализации.

UX при конфликте

Интерфейс не должен «молчать». При обнаружении конфликта:

  • Пользователь получает уведомление: «Обнаружено расхождение. Ваша версия и серверная версия отличаются»;
  • Предлагается выбор: «Оставить мою», «Принять серверную», «Объединить вручную»;
  • В случае ручного объединения — показываются обе версии (например, в split-view), и пользователь выбирает фрагменты.

Важно: конфликт — не ошибка, а нормальная ситуация в распределённых системах. Хороший UX превращает её в контролируемый процесс, а не в катастрофу.

UX-паттерны автономной работы

Техническая реализация бессмысленна без продуманного взаимодействия с пользователем. Ключевые принципы:

  1. Прозрачность состояния сети
    Не скрывать факт офлайна. Индикатор (например, иконка с перечёркнутым облаком) должен быть видим, но не агрессивен. Лучше — интегрировать в контекст: «Сохранено локально. Отправится при подключении» рядом с сообщением.

  2. Оптимистичные обновления
    Реакция на действия должна быть мгновенной: сообщение появляется в чате, задача — в списке, черновик — в редакторе. Отложенный характер синхронизации не должен ощущаться как задержка.

  3. Прогресс, а не спиннеры
    При фоновой синхронизации или загрузке — показывать прогресс: «3 из 5 сообщений отправлено», «Загружено 45 %». Это снижает тревожность.

  4. Управляемость
    Пользователь должен иметь возможность:

    • вручную запустить синхронизацию;
    • отменить ожидающую операцию;
    • очистить локальные черновики;
    • переключить режим кэширования (например, «только по Wi-Fi»).
  5. Обратная связь по результату
    После завершения фоновой задачи — уведомление не «Синхронизация выполнена», а «Сообщения доставлены», «Документ сохранён на сервере». Конкретика важна.

  6. Гарантии, а не обещания
    Избегать формулировок вроде «Данные будут отправлены позже». Лучше: «Данные сохранены локально и отправятся автоматически при восстановлении связи». Акцент на факте (сохранено), а не на будущем действии.

Тестирование автономности

Проверка работы без интернета не сводится к отключению Wi-Fi в DevTools. Необходимо моделировать:

  • Частичную доступность — DNS работает, TCP — нет; HTTP 200 приходит, но тело обрезано; сервер возвращает 5xx.
  • Переходы между режимами — онлайн → офлайн во время отправки; офлайн → онлайн при открытой вкладке.
  • Закрытие приложения в момент синхронизации — корректно ли восстанавливается очередь?
  • Долгосрочное хранение — данные, созданные месяц назад, корректно синхронизируются сегодня?
  • Конфликты версий — имитация одновременного редактирования с другого устройства.

Инструменты:

  • DevTools → Application → Service Workers (вручную остановить/активировать SW);
  • Network Throttling («Offline», «Slow 3G»);
  • Плагины для тестирования IndexedDB (например, idb-keyval с логированием);
  • E2E-тесты с принудительным отключением сети между шагами (Cypress, Playwright).