Бывает так, что realtime на проекте есть, а WebSocket — нет. Сервер отдаёт сообщения через long-polling (он же Comet): клиент шлёт «висящий» HTTP-запрос, сервер держит его открытым, пока не появятся новые сообщения, отвечает — и клиент тут же открывает следующий. С виду всё просто, по сути — бесконечный цикл из одного запроса.
Просто это ровно до первого запуска на реальном устройстве где-нибудь в метро. Дальше начинается то, ради чего я и пишу эту статью: гонки при переподключении, дубли локальных пушей, два потока сообщений в одном ответе и куча мелких состояний, которые надо аккуратно разруливать. Ниже — как с этим жить, на примере iOS-сервиса (назову его LongPollChatService).
Одна оговорка на весь дальнейший код: в сниппетах я опускаю синхронизацию, чтобы не размазывать идею. В реальном сервисе всё изменяемое состояние long-poll цикла — currentRequestUUID, курсоры, счётчики, словарь отложенных задач — живёт на одном serial context (у меня это отдельная очередь; в другом проекте это мог бы быть actor или main thread). Иначе сам механизм защиты от гонок легко сам становится гонкой, что было бы немного обидно)
Сам цикл: хвостовая рекурсия вместо while
WebSocket держит соединение, и события прилетают сами. С long-polling ты сам себе event loop: получил ответ — запросил снова. В коде это не while, а хвостовая рекурсия — метод запроса в случае успеха вызывает сам себя:
private func requestNewMessages(token: ChatToken, requestUUID: String) { apiManager.getMessages(token: token) { [weak self] response in guard let self else { return } guard self.currentRequestUUID == requestUUID else { return } // про это — ниже switch response { case .success(let messages): self.handle(messages) self.requestNewMessages(token: token, requestUUID: requestUUID) // снова в цикл case .failure: self.scheduleReconnect(token: token, requestUUID: requestUUID) // backoff } } }
Полная цепочка старта чуть длиннее: сначала берём токен сессии, потом синхронизируем курсоры (про которые мы пока не знаем — об этом дальше), и только потом уходим в этот «висящий» запрос. Но сердце — вот эти две строки: handle + повторный вызов себя.
Главная проблема: гонки при переподключении
Вот где начинается интересное. Long-polling-запрос живёт долго — секунды, иногда десятки секунд. За это время может случиться что угодно: пользователь свернул приложение, сменил аккаунт, потерял сеть. Нам надо перезапустить цикл. Но старый-то запрос уже летит, и его колбэк прилетит — возможно, уже после того, как мы всё перезапустили.
Если ничего не делать, получаем классику: два конкурирующих цикла, дубли сообщений, рассинхрон курсоров. Отменить сетевой запрос физически не всегда успеваешь — cancel() мог уже не догнать ответ.
Решение, которое мне зашло — токен поколения. У сервиса есть currentRequestUUID. Каждый старт цикла генерит новый UUID, и каждый колбэк первым делом сверяет «а я ещё актуален?»:
private var currentRequestUUID = "" private func startNewCycle() { let uuid = UUID().uuidString currentRequestUUID = uuid // объявляем новое «поколение» — ещё до запроса токена fetchToken { [weak self] token in guard let self, self.currentRequestUUID == uuid else { return } // токен устарел — выходим self.requestNewMessages(token: token, requestUUID: uuid) } } // и так — в начале каждого колбэка по всей цепочке: guard currentRequestUUID == requestUUID else { return } // я из старого поколения → молча умираю
Суть: мы не пытаемся героически отменить всё, что в полёте. Мы просто помечаем актуальное поколение, а всё устаревшее само себя глушит на входе в колбэк. stop() при этом обнуляет UUID полностью — и тогда вообще все летящие колбэки становятся no-op:
func stop() { currentRequestUUID = "" // теперь ни один guard выше не пройдёт requestsCount = 0 // сбрасываем счётчик активных циклов (про него — ниже) chatTokenRequest?.cancelRequest() messagesRequest?.cancelRequest() deferredTasks.forEach { $0.value.cancel() } deferredTasks.removeAll() isEnabled = false }
Поверх этого у меня ещё живёт инвариант «активный цикл ровно один». Счётчик requestsCount при нормальной работе всегда 0 или 1, с ассертом в debug, если вдруг стало больше:
guard requestsCount == 0 else { // активный цикл уже есть — второй не плодим assertionFailure("должен быть ровно один активный long-poll") return } requestsCount += 1 apiManager.getMessages(token: token) { [weak self] response in guard let self else { return } guard self.currentRequestUUID == requestUUID else { return } // устаревший колбэк — выходим self.requestsCount -= 1 // слот освобождаем только за «свой» цикл // ...обработка ответа... }
Тонкость — в порядке проверок: декремент стоит после сверки поколения, а не в defer. Так и должно быть. stop() сам обнуляет счётчик, поэтому устаревший колбэк, прилетевший уже после остановки, обязан выйти молча и к счётчику не прикасаться — иначе увёл бы его в минус и заблокировал следующий старт. Правило простое: requestsCount трогает только актуальный цикл, а stop() всегда возвращает его в ноль.
Честно — вся эта проверка скорее подстраховка от самого себя: UUID-токена в теории достаточно, но реальный код обрастает ветками (смена аккаунта, ретраи, возврат сети), и проще иметь громкую ассерт-проверку, чем потом ловить второй невидимый цикл по логам. Один раз она меня уже спасла.
Backoff с джиттером
Если запрос упал — нельзя ломиться переподключаться сразу же и в цикле, иначе при сетевом сбое все клиенты дружно заддосят сервер ровно в одну секунду. Нужен растущий интервал плюс случайный разброс (джиттер). И вот с джиттером легко промахнуться: если взять растущую задержку и просто прибавить-отнять к ней пару случайных секунд (base ± random), клиенты всё равно сгрудятся вокруг base — в узкой полосе. От той самой «толпы», что ломится переподключаться разом после сетевого сбоя, это спасает слабо.
Поэтому берут full jitter: задержка — это случайная точка по всему интервалу 0...cap, а не «где-то около base». Так попытки размазываются равномерно, и синхронного всплеска на сервере не возникает. Сам интервал при этом растёт экспоненциально и упирается в потолок:
private let baseDelay: TimeInterval = 2 // стартовая задержка private let maxDelay: TimeInterval = 30 // потолок private var attemptCount = 0 private func reconnectInterval() -> TimeInterval { defer { attemptCount += 1 } let capped = min(baseDelay * pow(2, Double(attemptCount)), maxDelay) // 2, 4, 8, 16 … ≤ 30 return .random(in: 0...capped) // вся ширина интервала, не «около base» }
После успешного ответа attemptCount сбрасывается в 0. Честно говоря, в проде у меня поначалу была версия попроще — ступенька с вычитанием случайных секунд; full jitter — это ровно то, к чему стоило прийти сразу.
Жизненный цикл: фон, сеть, и кто кого будит
Long-polling нельзя оставлять висеть бесконечно — в фоне его всё равно прибьёт система, а зря открытый запрос только жрёт ресурс. Поэтому цикл жёстко привязан к состоянию приложения и сети.
Старт идёт, только если есть активная foreground-сцена:
func run() { let isActive = UIApplication.shared.connectedScenes .contains { $0.activationState == .foregroundActive } if isActive { refresh() } else { stop() } }
А возврат сети будит цикл сам — через подписку на reachability. С одной оговоркой: реагируем только на переход «не было → появилась», иначе на каждый чих коннективити-менеджера будем дёргать перезапуск:
var wasConnected = connectivity.isConnected connectivity.addObserver(self) { [weak self] status in switch status { case .reachable where !wasConnected: self?.requestNewMessages() // сеть вернулась — оживаем wasConnected = true case .unreachable: wasConnected = false default: break } }
Отдельный слой — отложенные задачи (те самые ретраи с backoff). Это словарь [String: DispatchWorkItem]: каждая задача лежит по своему UUID-ключу и сама себя удаляет по завершении. Получается ручной планировщик поверх GCD — не самый элегантный, зато stop() гасит всё одним проходом по словарю (см. выше).
Два потока сообщений в одном ответе
Защитить сам цикл от дублей — это половина дела. Вторая половина в том, что внутри одного ответа может ехать больше одного независимого потока событий, и у каждого нужен свой курсор. У нас таких потоков ровно два: сообщения приходят сразу для двух «личностей» пользователя — основного аккаунта и привязанного (второй профиль, который можно прицепить и отцепить). Ответ — словарь, где ключ — это id пользователя, а значение — его сообщения:
// { "<основной userID>": [...], "<привязанный userID>": [...] } messages.forEach { key, containers in guard let userID = Int(key) else { return } switch userID { case primaryUserID: parse(containers, cursor: &primaryCursor) case linkedUserID: parse(containers, cursor: &linkedCursor) default: assertionFailure("прилетел userID, которого мы не ждали") } }
У каждого потока — свой курсор последнего полученного сообщения (primaryCursor / linkedCursor), они независимы. А прямо в этой же ветке обрабатывается привязка/отвязка второго аккаунта: если в ответе с токеном вдруг появился id привязанного профиля, которого раньше не было — значит, аккаунт только что прицепили, дёргаем делегат. Если пропал — значит, отцепили, и надо вычистить из локальной БД все его чаты и обнулить курсор:
if let linkedID = tokenResponse.linkedUserID { if linkedUserID != linkedID { // аккаунт только что привязали delegate?.linkedAccountDidChange(userID: linkedID) } linkedUserID = linkedID } else if linkedUserID != .invalid { // аккаунт отвязали dbManager.deleteChats(forLinkedAccount: linkedUserID) linkedUserID = .invalid linkedCursor = nil }
Не бог весть какая магия, но это тот случай, когда «два» вместо «одного» протекает через весь сервис: два курсора, две ветки парсинга, два состояния. Если будете проектировать похожее с нуля — закладывайте множественность потоков сразу, дешевле выйдет.
isFirstLoad: не задублировать пуши на старте
Тонкий продуктовый момент, который легко проворонить. Пока приложение лежало в фоне или было выгружено, сообщения копились — и на холодном старте мы вытягиваем весь этот хвост разом. Вопрос: показывать ли локальный пуш на каждое из них?
Нет. Пока приложение было в фоне, система уже показала по ним обычные remote-пуши — и если на старте мы добавим ещё и локальные, пользователь увидит каждое сообщение дважды. А вот сообщения, которые прилетают уже при открытом приложении (когда remote-пуш не показывается), пушить локально как раз надо — иначе их в интерфейсе ничего не подсветит.
Значит, надо отличить «догружаю накопившийся хвост» от «прилетело новое прямо сейчас». Для этого сравниваем максимальный id с сервера с локальным курсором:
// какой самый свежий id знает сервер на момент старта сессии let serverMax = max(lastMessageInfo.primaryLastID, lastMessageInfo.linkedLastID ?? .invalid) let localMax = max(primaryCursor, linkedCursor ?? .invalid) // сервер ушёл вперёд → это накопившийся в фоне хвост, локальные пуши по нему НЕ шлём isFirstLoad = serverMax > localMax
Пока isFirstLoad == true, мы догружаем хвост и молчим; как только курсоры догнали серверный максимум, флаг гаснет — и дальше каждое новое сообщение уже идёт с локальным пушем. По сути пара строк, а именно они отвечают за то, что приложение не заваливает пользователя дублями уведомлений на каждый запуск.
Рассылка наблюдателям: чистка и доставка одним проходом
Сервис раздаёт сообщения наблюдателям (экранам). Держатся они слабыми ссылками, так что при каждой рассылке надо попутно выкидывать тех, кто уже умер (observer == nil). И вот тут я сознательно делаю и то, и другое за один проход — прямо внутри предиката removeAll(where:):
observers.removeAll { info in guard info.observer.value != nil else { return true } // мёртвый → выпиливаем if let messages = messages(for: info) { DispatchQueue.main.async { info.handler(messages) // живому — доставляем, в том же проходе } } return false // живой остаётся в списке }
Логика простая: мы всё равно идём по массиву, чтобы вычистить мёртвые ссылки, — так почему бы заодно не разослать сообщения тем, кто жив? Делать ради этого второй отдельный forEach смысла мало: получится два прохода по тому же массиву там, где хватает одного. Да, формально это side-effect внутри предиката фильтрации, и тащить такой стиль повсюду я бы не стал — но здесь он осознанный: массив наблюдателей маленький, а «почистить мёртвых и доставить живым» — по смыслу одна операция.
Итого
Если оглянуться на весь сервис, видно одну вещь: почти вся его сложность — не про чаты как таковые, а про то, что long-polling по своей природе тащит состояние. Висящий запрос живёт долго и переживает любое изменение вокруг себя — смену экрана, аккаунта, сети. Поэтому самым важным оказался не парсинг сообщений, а аккуратное обращение с этим «долгоживущим» запросом: токен поколения, чтобы устаревшие колбэки гасли сами; инвариант на единственный активный цикл; и stop(), который честно подчищает за собой.
Три приёма, которые я бы забрал в любой следующий проект без сокетов:
UUID-токен поколения — самый дешёвый способ обезвредить устаревшие колбэки, не воюя с гонкой отмены сетевого запроса. Ложится на что угодно: long-polling, SSE, да хоть серию обычных запросов.
сравнение курсоров для пушей — буквально пара строк, а избавляет от дублей нотификаций на холодном старте.
джиттер на backoff — становится обязательным ровно в тот момент, когда клиентов больше одного.
Со временем это, наверное, переехало бы на Swift Concurrency со структурированной отменой — тогда половина ручного жонглирования UUID’ами и счётчиками ушла бы внутрь Task и его cancellation, а serial context стал бы actor’ом. Но даже сейчас, без корутин, главная мысль не меняется: в realtime без сокетов отмену и переподключение надо закладывать с первого дня, а не после первого бага в метро. Всё остальное — детали)
Комментарии (2)

anoshenko
23.06.2026 08:32Попробуйте server-sent events
https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events
Void-Cowboy
а чего grpc и прочие rpc под вашу задачу не использовать? как раз жонглирование реконектами и прочим уже на уровне сгенерированого кода