Outbox-паттерн для мобильного мессенджера: как Telegram не теряет сообщения и почему ваш код их теряет

от автора

Уровень: middle/senior мобильная разработка Стек: React Native, Expo SDK 54, XMLHttpRequest, AsyncStorage, TypeScript Что внутри: глобальная очередь исходящих сообщений, синхронный доступ из компонентов, переживание навигации и перезапуска приложения, ретрай токенов

Преамбула

Это седьмая статья про инженерные решения в ONEMIX. Тема узкая, но болезненная для каждого кто делал мобильное приложение с отправкой сообщений или файлов.

Сценарий с которого всё началось у меня. Пользователь в чате выбирает большое видео, нажимает отправить. Видео начинает грузиться. Пользователь нетерпеливый, прокручивает вверх посмотреть переписку, потом переходит в другой чат, потом возвращается. Что должен он увидеть?

В Telegram он увидит свой видео-бабл с прогрессбаром, как и оставил. В большинстве самописных мессенджеров он увидит пустой чат без своего сообщения, потому что upload жил в state экрана, а экран размонтировался. XHR продолжал работать в фоне, файл загрузился на сервер, но результат пришёл в null, потому что setter уже не существует. Сообщение фактически отправлено, но пользователь об этом не знает.

Это боль которая лечится не «правильным useState», а отдельным архитектурным слоем. Этот слой называется outbox. В этой статье разберу свою реализацию из ONEMIX, это 820 строк TypeScript которые делают то что в Telegram кажется естественным.

Почему обычный useState не работает

Стандартный код отправки в React Native выглядит примерно так:

function ChatScreen({ chatId }) {  const [pending, setPending] = useState([]);  const sendMedia = async (file) => {    const optimisticId = generateId();    setPending(prev => [...prev, { id: optimisticId, file, progress: 0 }]);    const formData = new FormData();    formData.append('file', file);    const result = await uploadWithProgress(formData, (progress) => {      setPending(prev => prev.map(p =>        p.id === optimisticId ? { ...p, progress } : p      ));    });    setPending(prev => prev.filter(p => p.id !== optimisticId));    // ... merge real message into messages list  };  return <MessagesList pending={pending} ... />;}

В учебниках это нормально. В жизни ломается сразу.

Проблема 1: размонтирование экрана. Пользователь переходит в другой чат. ChatScreen размонтируется, state теряется. XHR продолжает выполнение в замыкании промиса, но setPending и setMessages указывают на несуществующий компонент. Когда пользователь вернётся, он увидит чат без своего сообщения, как будто и не отправлял.

Проблема 2: перезапуск приложения. Юзер свернул приложение во время загрузки 200мб видео. iOS убил процесс через минуту. Когда юзер возвращается, нет ни сообщения, ни прогресса, ни даже знания что он что-то отправлял.

Проблема 3: одно сообщение в нескольких местах. Список чатов на главном экране тоже должен показывать «Видео отправляется…» как preview последнего сообщения. Если состояние внутри ChatScreen — другие экраны об этом не знают.

Проблема 4: переключение между чатами. Юзер отправил видео в чате А, переключился в чат Б. Если pending хранится в useState одного экрана, в чате Б он не отображается даже если относится к чату А.

Решение всех четырёх проблем одно: вынести pending за пределы экранов.

Архитектура: глобальный синглтон

Outbox это модуль с глобальным состоянием, который переживает навигацию, размонтирование, перезагрузку. Экраны на него подписываются, а не владеют им.

const _items = new Map<string, OutboxItem>();const _xhrs = new Map<string, XMLHttpRequest>();const _listeners = new Set<Listener>();// Структура одной записиinterface OutboxItem {  id: string;                 // optimistic ID (это же и UI key)  chatId: string;  kind: 'text' | 'media' | 'file' | 'voice' | 'video_note' | 'sticker';  text?: string;  attachmentType?: string;  replyToId?: string;  file?: OutboxFile;  phase: 'queued' | 'uploading' | 'sending' | 'sent' | 'failed';  loaded: number;  total: number;  errorMessage?: string;  createdAt: number;  optimisticMessage: any;     // готовое "сообщение" для рендера}

_items живёт на уровне модуля, а не компонента. Когда ChatScreen размонтируется, Map не исчезает. Когда ChatScreen монтируется заново, он спрашивает у outbox «что у тебя есть для этого чата» и получает актуальный список.

_xhrs хранит активные XHR-инстансы. Без этого нельзя отменить upload, нельзя получить прогресс, нельзя реагировать на завершение когда оригинальный promise забыт. XHR существует вне компонента, в собственной памяти outbox.

_listeners — подписчики событий. ChatScreen при монтировании подписывается на изменения, при размонтировании отписывается.

Пять фаз сообщения

Outbox-сообщение проходит через состояния:

queued — добавлено в очередь, ещё ничего не начато. Микро-задержка перед uploading.

uploading — XHR качает файл. Идёт прогресс.

sending — файл загружен на сервер, осталось вызвать api.sendMessage с url’ом. Это отдельная фаза потому что api.sendMessage тоже может упасть на плохой сети.

sent — успех. Сообщение пришло с сервера с реальным id. Удаляем оптимистичную запись из outbox, заменяем в UI на реальное сообщение.

failed — ошибка. Сообщение остаётся в outbox с кнопкой «Повторить». При retry возвращается в queued.

Текстовые сообщения идут сразу в sending, минуя uploading. у них нет файла. Но защита от исчезновения нужна всё равно, потому что api.sendMessage на плохой сети может выполняться 10 секунд, и за это время юзер успеет уйти из чата.

Публичный API из четырёх функций

Снаружи outbox выглядит элегантно. Четыре функции для записи:

// Добавить медиа (фото, видео, файл, голосовое, кружок, стикер)enqueueMedia({  chatId, file, attachmentType, caption, replyToId,  optimisticMessage, expectedSize}): string  // вернёт optimistic ID// Добавить простой текстenqueueText({  chatId, text, replyToId, optimisticMessage}): string// Прямая отправка (файл уже на сервере: стикер, пересылка)enqueueDirect({  chatId, text, attachmentUrl, attachmentType, replyToId, optimisticMessage}): string// Группа из нескольких медиа в одном сообщенииenqueueMediaGroup({  chatId, mediaGroup, caption, replyToId, optimisticMessage}): string

Каждая возвращает optimisticId, строку которую ты можешь использовать как key в React-списке, и по которой потом можно отменить или повторить отправку. Это тот же id который попадёт в UI как ключ оптимистичного сообщения.

Три функции для управления:

cancel(optimisticId)   // прервать XHR, удалить из outboxretry(optimisticId)    // повторить failedgetProgress(optimisticId) // снапшот прогресса

И три для подписок:

subscribe((item) => { ... })   // изменения фазы и прогрессаsubscribeReplace((optimisticId, realMessage) => { ... })  // sentsubscribeRemove((optimisticId) => { ... })  // cancel или unrecoverable failure

Подписки возвращают функцию отписки. useEffect ставит подписку, в cleanup отписывается. Никаких leaks.

Синхронный доступ, ключевое решение

Самое важное в outbox это синхронный доступ при маунте компонента. Когда ChatScreen возвращается в чат, он должен немедленно в первом же рендере увидеть все pending-сообщения. Не через 100мс после fetch, не через useEffect, а прямо сейчас.

function ChatScreen({ chatId }) {  // Синхронно, без async, без spinner  const [pending, setPending] = useState(() => outbox.getPendingForChat(chatId));  useEffect(() => {    const unsub = outbox.subscribe((item) => {      if (item.chatId !== chatId) return;      setPending(outbox.getPendingForChat(chatId));    });    return unsub;  }, [chatId]);  // ... render с pending как обычные сообщения}

getPendingForChat это for по Map, никаких асинхронных операций. Возвращает массив прямо из памяти. В первый кадр уже всё отрисовано.

export function getPendingForChat(chatId: string): OutboxItem[] {  const arr: OutboxItem[] = [];  for (const it of _items.values()) {    if (it.chatId === chatId && it.phase !== 'sent') arr.push(it);  }  return arr.sort((a, b) => a.createdAt - b.createdAt);}

Это тот же приём что у меня в кэше сообщений из первой статьи серии: синхронный L1 доступ для мгновенной отрисовки. Outbox это, по сути, ещё один L1, только для исходящих сообщений вместо входящих.

Персистенция через AsyncStorage

XHR в AsyncStorage не сохранишь. Но метаданные сохранять надо, иначе после перезапуска юзер потеряет понимание что он что-то отправлял.

При каждом изменении outbox планирует асинхронную запись:

let _persistPending = false;function schedulePersist() {  if (_persistPending) return;  _persistPending = true;  setTimeout(() => {    _persistPending = false;    const arr = Array.from(_items.values());    AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(arr)).catch(() => {});  }, 300);}

Debounce 300мс, не пишем после каждого onprogress, иначе AsyncStorage будет в постоянной нагрузке от прогресса. Пишем не чаще раза в 300мс.

При старте приложения восстанавливаем:

export async function preloadOutbox(): Promise<void> {  if (_loaded) return;  _loaded = true;  const raw = await AsyncStorage.getItem(STORAGE_KEY);  if (!raw) return;  const arr: OutboxItem[] = JSON.parse(raw);  for (const it of arr) {    // После перезапуска все active загрузки становятся failed —    // XHR умер вместе с процессом. Пользователь увидит retry-кнопку.    if (it.phase === 'uploading' || it.phase === 'sending' || it.phase === 'queued') {      it.phase = 'failed';      it.errorMessage = 'Прервано при перезапуске';    }    _items.set(it.id, it);  }}

Ключевая деталь. Все uploading/sending/queued после рестарта становятся failed. XHR не пережил процесс, продолжать его нельзя. Зато юзер откроет приложение, увидит сообщение с пометкой «Прервано при перезапуске» и кнопкой «Повторить». Это сильно лучше чем «сообщение исчезло без следа».

Вызывается из _layout.tsx параллельно с другими preload-операциями, до первого рендера:

await Promise.all([  preloadCacheInstant(),  preloadOutbox(),  // ... другие preload]);

XHR через XMLHttpRequest, а не fetch

В коде upload использует XMLHttpRequest, не fetch. Это намеренно.

fetch в React Native не даёт прогресса загрузки. Можно слушать прогресс скачивания через streaming response, но прогресс отправки через FormData недоступен. А для UI критично показать «загружено 47мб из 200мб». Без этого юзер не понимает что вообще происходит и думает что приложение зависло.

XMLHttpRequest даёт upload.onprogress со свойствами loaded и total. Это работает:

const xhr = new XMLHttpRequest();xhr.open('POST', `${baseUrl}/upload`);xhr.setRequestHeader('Authorization', `Bearer ${token}`);xhr.upload.onprogress = (e) => {  if (!e.lengthComputable) return;  const cur = _items.get(optimisticId);  if (!cur) return;  cur.loaded = e.loaded;  cur.total = e.total;  notify(cur);};xhr.onload = () => {  // ... обработка успеха};xhr.onerror = () => {  // ... обработка ошибки сети};_xhrs.set(optimisticId, xhr);xhr.send(formData);

_xhrs.set критично. Без этого нельзя отменить запрос. Когда юзер нажмёт «отменить», мы делаем xhr.abort() для конкретного optimisticId. Без хранения ссылки нет способа дотянуться до запущенного XHR.

Грабли которые я собрал

Грабля 1: 401 на середине upload. Юзер открыл чат, начал загружать большой файл, access_token истёк за время загрузки. Запрос вернётся с 401, и upload потеряется зря.

Решение, один retry с обновлением токена:

xhr.onload = () => {  if (xhr.status === 401 && !cur._uploadRetried) {    cur._uploadRetried = true;    api.tryRefreshToken().then((ok: boolean) => {      if (ok) {        doUpload(); // повторяем тот же upload      } else {        cur.phase = 'failed';        cur.errorMessage = 'Upload failed: 401';      }    });    return;  }  // ... остальная логика};

Флаг uploadRetried чтобы не уйти в бесконечный цикл если refreshtoken тоже истёк. Один retry, этого хватает в 99% случаев.

Грабля 2: cancel во время onload. Юзер нажимает «отменить» в момент когда XHR уже отправил данные и сервер уже сохранил файл, но onload ещё не отработал. Если просто отменить, onload отработает позже и попытается завершить sending для удалённой записи. Решение — флаг _cancelled:

const _cancelled = new Set<string>();export function cancel(optimisticId: string) {  _cancelled.add(optimisticId);  // ... abort xhr, delete from _items}xhr.onload = () => {  if (_cancelled.has(optimisticId)) {    _cancelled.delete(optimisticId);    return; // выходим тихо  }  // ... обычная обработка};

Set очищает сам себя. После того как onload выйдет, флаг удаляется.

Грабля 3: file:// URI на iOS vs Android. На iOS expo-image-picker возвращает file:///private/var/.... На Android — это content://media/.... На некоторых старых Android без префикса вообще. XHR ведёт себя по-разному, FormData тоже:

let resolvedUri = file.fileUri;if (!file.fileUri.startsWith('file://')    && !file.fileUri.startsWith('ph://')    && !file.fileUri.startsWith('content://')    && !file.fileUri.startsWith('http')) {  resolvedUri = 'file://' + file.fileUri;}formData.append('file', { uri: resolvedUri, type: mime, name: file.filename });

Префикс file:// обязателен, иначе React Native иногда читает как путь к npm-модулю и крашится.

Грабля 4: прогресс для группы файлов. Юзер выбирает 5 фоток и отправляет как медиагруппу. Один прогрессбар на 5 файлов. Считать общий прогресс байтами не получается, файлы разного размера, и пока первый закончится прогрессбар будет показывать 0%.

Решение, нормализованный прогресс. Каждый файл занимает равную долю (1/N), и текущий прогресс это доля завершённых файлов плюс прогресс текущего файла внутри его доли:

const total = mediaGroup.length;let index = 0;xhr.upload.onprogress = (e) => {  if (!e.lengthComputable) return;  cur.loaded = index / total + (e.loaded / e.total) / total;  cur.total = 1; // нормализованный  notify(cur);};

Юзер видит плавно растущий прогрессбар 0% → 100%, неважно что внутри 5 разных файлов разного размера.

Что бы я сделал по-другому

Использовать react-native-blob-util вместо голого XHR. Я писал XHR-логику руками потому что хотел полный контроль. На практике у меня в коде уже несколько мест где обрабатываются edge cases которые библиотеки решают из коробки — фон iOS, multipart разделители, MIME detection. Если бы начинал сейчас, попробовал бы готовую библиотеку и переключил бы её на свой XHR только если упрусь в её ограничения.

Background upload через native module. На iOS если приложение ушло в фон, JS thread замораживается через 30 секунд, и XHR умирает. Telegram продолжает upload в фоне через native NSURLSessionConfiguration с backgroundSessionConfiguration. У меня этого нет — большие файлы при сворачивании прерываются. Решение требует native кода, и я отложил его в roadmap.

Очередь с приоритетами. Сейчас все upload идут параллельно. Если юзер отправил 10 файлов подряд — все 10 XHR держат сеть. Правильнее — очередь с лимитом параллельности (2-3) и приоритетом для маленьких файлов (текст и стикеры идут впереди видео). Это в TODO.

Retry с экспоненциальной задержкой. Сейчас если файл упал — он остаётся в failed пока юзер не нажмёт retry. Правильнее — автоматический retry через 5, 15, 60 секунд для transient errors (502, 503, timeout). Сетевые ошибки часто кратковременные, юзеру не обязательно их видеть.

Заключение

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

В Telegram это работает так естественно, что не замечаешь. В большинстве самописных мессенджеров это сломано, и пользователи быстро находят способы потерять свои сообщения. Если делаете мессенджер и не сделали outbox-слой — у вас скорее всего есть этот баг, просто вы его пока не заметили.

Главный урок который я извлёк за время этой работы: архитектурные слои которые «невидимы» для юзера и есть та инженерия которая отличает product-grade приложение от прототипа. Кэш сообщений, outbox, ratchet-state, retry-логика всё это работает в фоне и никогда не показывает себя. Но без них приложение ломается в самых неудобных местах.


Это седьмая статья из серии. В предыдущих было про мобильный мессенджер ONEMIX: трёхуровневый кэш, Double Ratchet E2E, WebRTC звонки, vanilla Electron, мнение про вайб-кодинг и про AI-агента Лиру.

Если интересна реализация конкретных кусков (background upload, очередь с приоритетами, синхронизация outbox с server-side ack), пишите в комменты.

ссылка на оригинал статьи https://habr.com/ru/articles/1034690/