Иногда на проекте 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 без сокетов отмену и переподключение надо закладывать с первого дня.

Отвечая на комментарии к прошлой статье:

Попробуйте server-sent events https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events

а чего grpc и прочие rpc под вашу задачу не использовать? как раз жонглирование реконектами и прочим уже на уровне сгенерированого кода

Когда я пришел на проект в 2020-м году, то уже был готовый сервис на long-polling, так что выбора у меня, что использовать, к сожалению, не было. Было желание перейти на websocket (в тот момент я предлагал Socket.IO), но наш CTO решил не переделывать всё. В целом, У обоих подходов есть свои минусы и плюсы, с long-polling вполне хорошо жилось в целом

Комментарии (10)


  1. Void-Cowboy
    24.06.2026 10:00

    вроде ж была уже такая статья недавно?

    и собсвенно по теме статьи - если это приложение то можно grpc юзать для такого (это даже лучше)

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


    1. vientooscuro Автор
      24.06.2026 10:00

      Да, её почему-то перенесли в черновики, я доработал


    1. vientooscuro Автор
      24.06.2026 10:00

      Да, я дополнил про grpc в статье. У нас был уже готовый большой сервис на long-polling – это было решение CTO, а отдельный реалтайм-чат мы в тот момент не планировали под наше приложение, была только идея прикрутить чат того сервиса. А потом перенесли чаты, уперлись в ограничения архитектуры (к примеру, в групповых чатах все сообщения хранились в N-копиях, и пока групповые чаты были небольшими - 2-4 человека, - это было ок, а вот когда они разрослись, то стало совсем плохо, сначала ограничили счетчик количества новых сообщений (условно, 500+), а потом пришлось бэку вообще архитектуру менять


      1. Void-Cowboy
        24.06.2026 10:00

        всегда лучше закладывать изначально возможность читать батчами для клиента, даже когда кажется что там "всего ничего данных")))

        как минимум помогает в таких ситуациях, но вообще спасает и при слабом нестабильном интернете например

        в вашей задаче можно было к примеру складывать в inline-json сообщения (как логи пишутся) и тогда ограничения на сообщения вообще нет, вы просто просите с сервера первые 100кб файла например, то есть всегда актуальные данные. Если заполировать etag то латенси на такой "онлайн" будет даже в пределах допустимого))


        1. vientooscuro Автор
          24.06.2026 10:00

          Всегда лучше закладывать масштабируемость, о чем я говорил ещё в 2020-м, когда только пришел, но меня решили не слушать) С long-polling был в целом валидный аргумент – у ВК так же реализовано. На бэке это упрощает по сравнению с вебсокетами архитектуру, так как http сильно проще, но на клиенте получаем постоянные переподключения, что вообще-то сильно затратно, особенно в условиях 3g-интернета или медленного lte

          Но вообще я пока встречал чаты только на websocket (в 4 местах, где я работал), и long-polling в одном. Интересно было бы посмотреть, как делают другие


          1. Void-Cowboy
            24.06.2026 10:00

            серьезные вещи стараются упаковать в свой протокол, причем зачастую utp плюс легкий tcp. Причем пакуют максимально сжато, бинарно, зачастую даже не протобафами а своей оптимальной под задачу разметкой

            фронт-енд же только и только вебсокеты. Видел реализации где пулинг раз в 20сек но там много что было на уровне заголовков, из за чего 90% ответов сервера "холодные" со статусом "без изменений"

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


          1. akardapolov
            24.06.2026 10:00

            так как http сильно проще, но на клиенте получаем постоянные переподключения, 

            HTTP/3 же решает эту проблему (там QUIC который использует UDP)?

            чаты только на websocket

            Еще есть WebTransport, но пока не очень популярный.


            1. vientooscuro Автор
              24.06.2026 10:00

              HTTP/3 же решает эту проблему (там QUIC который использует UDP)?

              Решает, да, вы правильно заметили, но мы в тот момент поддерживали устройства, у которых поддержки HTTP/3 не было (в iOS 15 появилась), а в Android – не знаю. Также ни я, ни наш CTO, кажется, в подробности HTTP/3 в то время не вдавались, я сам читал только год назад про это, но уже некуда было применять

              WebTransport – что-то новое для меня, спасибо большое, изучу!


  1. vanonovch11
    24.06.2026 10:00

    Long-polling действительно быстро превращается в ад, если не контролировать состояние и переподключения. Самое важное тут правильно разрулить жизненный цикл запроса, гасить устаревшие колбэки через UUID, держать один активный цикл и аккуратно делать retry с backoff. Тогда даже без WebSocket всё работает предсказуемо.


    1. vientooscuro Автор
      24.06.2026 10:00

      В нашем случае спроектировать было довольно просто, у нас это была наименее проблемная часть, чуть повозиться нужно было с локальными уведомлениями, записью в бд и отправку в чат, но в целом проходило всё гладко. Единственный момент, который напрягал, это что раз в 30с рвется соединение, а если приходят уведомления часто, то и чаще. А также из-за разрывов соединения бэк иногда считал, что устройство оффлайн, и отправлял пуш: при плохой связи же переподключение может быть длительным. С вебсокетом такие проблемы тоже есть, но реже