惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

D
Docker
AI
AI
博客园 - 三生石上(FineUI控件)
腾讯CDC
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
Y
Y Combinator Blog
大猫的无限游戏
大猫的无限游戏
H
Hackread – Cybersecurity News, Data Breaches, AI and More
雷峰网
雷峰网
NISL@THU
NISL@THU
S
Schneier on Security
T
Threatpost
T
Tenable Blog
Application and Cybersecurity Blog
Application and Cybersecurity Blog
IT之家
IT之家
宝玉的分享
宝玉的分享
T
Tailwind CSS Blog
C
Cybersecurity and Infrastructure Security Agency CISA
P
Privacy & Cybersecurity Law Blog
I
Intezer
Microsoft Azure Blog
Microsoft Azure Blog
月光博客
月光博客
T
Threat Research - Cisco Blogs
SecWiki News
SecWiki News
AWS News Blog
AWS News Blog
博客园 - Franky
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
P
Proofpoint News Feed
V
V2EX
Recorded Future
Recorded Future
Microsoft Security Blog
Microsoft Security Blog
S
Secure Thoughts
Google DeepMind News
Google DeepMind News
MongoDB | Blog
MongoDB | Blog
Apple Machine Learning Research
Apple Machine Learning Research
Project Zero
Project Zero
PCI Perspectives
PCI Perspectives
G
GRAHAM CLULEY
Help Net Security
Help Net Security
Cloudbric
Cloudbric
Recent Announcements
Recent Announcements
V
Visual Studio Blog
Hacker News: Ask HN
Hacker News: Ask HN
N
News and Events Feed by Topic
C
CERT Recently Published Vulnerability Notes
The Cloudflare Blog
Forbes - Security
Forbes - Security
C
Cisco Blogs
O
OpenAI News
www.infosecurity-magazine.com
www.infosecurity-magazine.com

Все публикации подряд на Хабре

Ловим музу за клавиатуру: как айтишнику стать автором Что умеет Midjourney в 2026? Мой немного грустный разбор этого шикарного инструмента Никто не любит писать тесты, но ИИ может исправить это IPv8 выглядит как мечта. Поэтому почти наверняка не взлетит Производители вернули в продажу материнки с DDR3. Что происходит? Управление агентом с телефона через Telegram теперь в KodaCode От координации к лидерству: как меняется роль руководителя разработки Я сделала родителям бизнес вместо пенсии: зарабатываем 70 тысяч, мама не даёт продать В три раза быстрее приемка товара и оптимизация трудозатрат на 73%: как «РСТ-Инвент» помог Gulliver Group ИИ-шечный мир победил? О влиянии искусственного интеллекта на игропром Кремль снижает давление на Телеграмм пока Европа строит интернет по паспорту Как CEO, CTO и CIO за 8 часов собрали ИИ-директора, который умеет держать позицию под давлением Как (не) потерять домен за выходные Вместо 8 разных VPS: как я организовал практику студентам на одном сервере Почему твой Open Source проект не замечают? R&D: искусство управления неопределенностью в разработке AI-дефляция: вакансий для разработчиков больше, а рост зарплат — худший за 15 лет Мы отдали управление роботами OpenClaw. Что из этого вышло Галактический ID: система идентификации для всех форм разумной жизни Шесть основ бизнес-анализа: начинаем с вопроса «Кто в игре?» Код-ревью, в котором дело не в коде Данные переехали. Команда — нет Системной подход к сдаче OSWE в 2025 Почему комната управления реактором покрашена в цвет морской пены 4 YAML-файла вместо PySpark: как аналитикам строить пайплайны без разработчиков LLM-агент для поиска свободных доменов: автоматизируем подбор Когда, зачем и как правильно начинать новую сессию в Claude Code? Как я заставил нейросеть писать макросы для FreeCAD Анатомия ИИ‑агента для подбора персонала. От тысячи резюме к топ‑10 за минуты Опыт разработчика как экономика внимания Автономность как точка невозврата: кто будет субъектом в цифровом будущем Обучение ИИ в «диких» условиях: как рутинные действия превращаются в датасеты Как измерить LLM для задач кибербеза: обзор открытых бенчмарков Где хранить код? Сравнение GitHub, GitLab и Bitbucket Математика объясняет, почему нормальное распределение встречается повсюду Почему ваш FinOps не работает: 12 тезисов от практиков Как подписать проектную документацию УКЭП с использованием бесплатных лицензий Pilot Адаптивное администрирование Sigla Vision Я грузил уран в бочки, а потом 20 лет строил ИТ в атомной отрасли Чем позвонить с Эвереста? История и обзор спутниковой связи. Часть 2 Как языковая модель помогает контролировать качество инструктажей по охране труда в металлургии Как не передать на desktop свой IP в РКН Анатомия SAP Privileges: как устроено управление правами в macOS MoneyDev: Сказка про три главных слова Обновлённый токенизатор видео K-VAE 2.0 от Сбера Как сделать диспетчеризацию дома на 1284 квартиры почти бесплатно Как мы разогнали железную дорогу Мы дали агентам рутину. Теперь надо решить — что делать с освободившимся временем Токсичный контент, промпт-хакинг и защита ИИ — всё о Guardrails для LLM Умный город начинается с точного взгляда: как «Фалькон Тех» меняет пространство к лучшему Навайбкодил приложение для анализа графов Почему Дюну так интересно читать? Упрощаем работу с рутиной или как стать Гендальфом Белым Деконструкция Go: CPU, RAM и что там происходит. Go Assembler база. Часть 1.1 Какие профессии исчезнут из-за ИИ, а какие появятся? И что с этим делать Как мы построили IT-отдел, где хочется расти: архитектурные встречи, прозрачные метрики и книжные подарки Rufler: Делаем из Claude Code автономный рой через один YAML-конфиг Sing-box и белый список приложений Как построить надёжный обмен сообщениями в микросервисах: лучшие практики для enterprise OpenAI строит MLM-пирамиду, а McKinsey и Accenture помогают ей в этом Дом, который не построил Фишер (Часть 2) «Сверхзвуковой математик» против «Вдумчивого логиста»: битва алгоритмов 3D-упаковки Мультимодальные модели – грубый и дорогой инструмент Разговоры ничего не стоят. Код тоже Проверки физических лиц: с кого начнет ФНС Топ-10 бесплатных нейросетей для создания видео в 2026 году Первые слои кода: как наши решения сегодня определяют архитектуру ИИ на десятилетия Разработка нового статического анализатора: PVS-Studio JavaScript Поиск уязвимостей ПО: базовый минимум или роскошный максимум Почему оценка персонала не работает как инструмент управления Как мы разработали ИИ-ассистента и сократили рутину продуктовой команды на 50% Как я ушел из найма, нажарил косточек и продал на маркетплейсах на 168 млн в год Когда 1С:ERP уже внедрена, а нормального производственного плана всё ещё нет Как я сделал Claude мультимодальным, подключив к нему Qwen Omni Как приглашение на вакансию мечты превращается в атаку Infrastructure as Code: философия и лучшие практики IaC Тестируем Yandex Code Assistant на задаче, в которой нужно хранить секреты nxs-universal-chart v3.0: новое поколение универсального Helm-чарта Callback Injection: Техника, которая отправила Microsoft Defender в глухой нокаут «Все идеи на стол»: митап как способ вывести проект из тупика Сегодня я узнал нечто новое о GPU благодаря багу в своей игре Как заставить LLM ̶ ̶г̶а̶л̶л̶ю̶ ̶ эволюционировать Карта событий как фундамент аналитики: практический кейс для E-commerce Что выбрать для AI: x86, ARM или RISC-V? Дайджест железа за март Роль соматических мутаций в развитии аутоиммунных заболеваний: путь к избирательной терапии Mythos от Anthropic — тревожный сигнал для всех, а не только для банков Guardrails для LLM на Java: как приручить промпт‑инъекции и токсичные ответы Green-VLA: как мы собрали VLA-модель для реального антропоморфного робота и не потеряли обобщение Финансовая гонка вооружений: почему умные люди добровольно в ней участвуют Эра ИИ-агентов наступила: выбираем лучшего цифрового сотрудника # Практический опыт внедрения WinCC Redundancy на производственном предприятии Сделал MVP за 3 дня, а потом неделю прикручивал оплату. Оно того стоило? Физика против Маска: почему Starship V3 может оказаться ещё одной катастрофой Нефть Венесуэлы: крупнейшие запасы в мире, но не крупнейшая нефтяная держава JPA 4. Переосмысление Hibernate Почему зеркальная фотокамера Nikon D5 десятилетней давности идеально подошла для миссии «Артемида-2» Проект «Уровень-Спутник» или как мы сделали платформу для гидрологов «Замедлиться, чтобы ускориться»: почему ИИ повышает цену ошибок в требованиях и архитектуре Как с нуля поднять трафик IT-компании на 1657% при бюджете 55 тыс. и выжить Pixel-perfect Downsampling — идеальная отрисовка 50 миллионов точек без потерь
Outbox‑паттерн для мобильного мессенджера: как Telegram не теряет сообщения и почему ваш код их теряет
Виктор · 2026-05-13 · via Все публикации подряд на Хабре

Средний

11 мин

7.9K

Уровень: 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, удалить из outbox
retry(optimisticId)    // повторить failed
getProgress(optimisticId) // снапшот прогресса

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

subscribe((item) => { ... })   // изменения фазы и прогресса
subscribeReplace((optimisticId, realMessage) => { ... })  // sent
subscribeRemove((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), пишите в комменты.