Очереди, потоки, события
UX поверх async-бэкендов: optimistic UI, состояния ожидания, read-your-writes
Пользователь жмёт «Опубликовать», спиннер останавливается, тост говорит «Опубликовано!» — а поста нигде нет. Тикеты в саппорт растут. Баг: POST вернул 202 Accepted и сбросил работу в Kafka-топик; консьюмер пишет пост через ~800мс. Фронтенд воспринял 202 как 200, рефетчнул список до того, как консьюмер отработал, получил пустой результат и поверил ему. UI был не медленным. Он был уверенно неправ насчёт записи, которой ещё не было.
Окно консистентности теперь твоя проблема
В тот момент, когда запись перестаёт быть синхронной — уходит в очередь, на шину событий, к воркеру — появляется разрыв между «сервер принял» и «сервер вернёт это при чтении». Этот разрыв — окно консистентности. При нормальной нагрузке это может быть 50–300мс. При backpressure, отстающем консьюмере или ребалансе partition оно растягивается до секунд. Команда бэкенда видит в этом выигрыш по пропускной способности. Пользователь переживает это как то, что приложение забыло, что он только что сделал.
Первое, что это ломает, — read-your-own-writes: гарантия, что я вижу своё изменение немедленно, даже если другие пользователи ещё нет. Наивный сценарий — POST, потом рефетч списка — нарушает её постоянно, потому что рефетч гонится с консьюмером и обычно выигрывает. Собственная правка пользователя исчезает с его же экрана. Это самый частый «баг-призрак» на async-бэкендах, и он не случайный: это детерминированный итог доверия чтению, которое произошло внутри окна консистентности.
Optimistic UI: применяй локально, согласовывай потом
Рефлекс сеньора — optimistic UI: применить изменение к локальному состоянию в момент действия пользователя, отправить запрос в фоне и согласовать, когда придёт истина. Обоснование не эстетическое — это пороги восприятия, которые Нильсен задокументировал в 1993 и которые до сих пор держатся: ~100мс ощущается мгновенным (прямое манипулирование), ~1с удерживает пользователя в потоке, но он замечает ожидание, а ~10с — предел, за которым внимание рвётся и он считает, что всё упало. Async-пайплайн легко пробивает 1с по полному кругу, так что реальный round-trip быстрым не сделать — ты делаешь воспринимаемый ~0мс, рендеря предсказанный результат сразу.
У контракта три части, и третью джуны пропускают:
- Применить предсказанное изменение к локальному состоянию и показать его.
- Отправить запрос; сохранить достаточно контекста, чтобы откатить.
- Согласовать — при успехе заменить предсказание серверной истиной (она может отличаться); при ошибке откатить к снимку, который ты взял перед применением.
TanStack Query кодирует ровно это в useMutation: onMutate отменяет рефетчи в полёте, делает снимок кэша и пишет оптимистичное значение (возвращая снимок как контекст); onError восстанавливает этот снимок; onSettled вызывает invalidateQueries, чтобы рефетчнуть реальное состояние. React 19 поставляет useOptimistic для облегчённой, привязанной к transition версии. Форма везде одна: снимок → предсказание → согласование-или-откат.
Почему это работает
Optimistic UI молча предполагает, что клиент может предсказать результат сервера. Это работает для «добавить эту задачу», но ломается для «сервер назначает номер заказа», «сервер применяет скидку» или «другой пользователь уже это изменил». Когда предсказание и согласованная истина расходятся, ты обязан показать исправленное значение — а не держать оптимистичное. Оптимистичное обновление, которое никогда не согласуется, — это просто ложь с лучшей задержкой.
Состояния ожидания лучше фейкового успеха
Когда ты не можешь безопасно предсказать исход (платёж списан? видео перекодировано? экспорт готов?), честный ход — явное состояние ожидания/обработки, а не фейковое «готово». Покажи, что работа в полёте, дай ей аффорданс — шаг прогресса, ETA, «мы пришлём письмо» — и дай UI сойтись, когда событие придёт. Кардинальный грех — схлопнуть 202 Accepted в тост успеха: ты говоришь пользователю, что что-то завершилось, хотя оно ещё лежит в очереди.
Здесь живут два провала. Первый — бесконечный спиннер: UI ждёт событие, которое никогда не придёт (консьюмер упал, сообщение ушло в dead-letter), и крутится вечно. Каждому состоянию ожидания нужен путь timeout + согласование — через N секунд опросить авторитетный эндпоинт или показать «всё ещё обрабатывается, зайдите позже». Второй — двойная отправка при повторе: пользователь не видит обратной связи, кликает снова, и теперь в очереди два сообщения. Без idempotency key это два списания, два заказа, два письма.
| Ситуация | Правильный UX-паттерн | Почему |
|---|---|---|
| Результат предсказуем (добавить задачу, лайк, переименовать) | Optimistic UI + откат | Воспринимаемая задержка ~0мс; согласование на settle |
| Результат непредсказуем (платёж, перекодирование, экспорт) | Явное состояние ожидания + timeout | Нельзя сфабриковать значение, которого не знаешь; не показывай «готово» рано |
| Рефетч сразу после записи | Локальное эхо / sticky-чтение | Рефетч гонится с консьюмером и выигрывает → нарушение read-your-writes |
| Пользователь повторяет / двойной клик | Idempotency key на намерение | Один ключ = эффект at-most-once; нет двойного списания |
Когда сталкиваются два async-обновления
Eventual consistency означает, что две записи могут быть в полёте одновременно — твоя и коллеги, или с твоего телефона и ноутбука. Согласование обязано выбрать победителя, и стратегия — это реальный трейдофф. Last-write-wins (LWW) штампует каждую запись timestamp и оставляет последнюю; это тривиально и без состояния, но молча отбрасывает данные проигравшего и зависит от синхронных часов — clock skew между нодами может дать победу более старой правке. Merge-логика (по полям или три-сторонняя) сохраняет обе стороны, когда изменения не пересекаются, но требует доменных правил. CRDT (conflict-free replicated data types) позволяют репликам слиться в любом порядке к одному результату без координации — основа real-time коллаб-редакторов — но накапливают метаданные и могут расти неограниченно без сборки мусора. Для «переименовать» LWW годится; для общего документа LWW уничтожает текст, поэтому тянешься к CRDT.
Работа фронтенда — сделать выбранную стратегию читаемой: если LWW отбросил изменение пользователя — скажи об этом и предложи применить заново; если произошёл merge — покажи, что слилось; вынеси 409 Conflict как реальный выбор («их версия / твоя версия / merge»), а не молчаливую перезапись.
Пользователь жмёт «Отметить выполненным» на задаче, которая идёт через очередь (консьюмер пишет через ~500мс). Выбери UX.
POST, сбрасывающий работу в очередь, возвращает 202 Accepted за 40мс; консьюмер пишет строку через ~700мс. UI делает POST и сразу рефетчит список. Что увидит пользователь?
Кнопка «Оплатить» показывает спиннер. Событие, подтверждающее списание, никогда не приходит (консьюмер упал). Без дополнительной обработки что произойдёт?
Расставь оптимистичную мутацию против async-бэкенда (контракт TanStack Query):
- 1 onMutate: отменить рефетчи в полёте, чтобы они не затёрли оптимистичное значение
- 2 onMutate: сделать снимок текущего кэша (чтобы можно было откатить)
- 3 onMutate: записать предсказанное значение в кэш и вернуть снимок как контекст
- 4 onError: восстановить снимок из контекста — откатить предсказание
- 5 onSettled: invalidateQueries, чтобы согласоваться с реальным состоянием сервера
- 01Почему «POST, потом сразу рефетч» ломает read-your-own-writes на async-бэкенде, и как это починить, не отказываясь от обновления?
- 02Когда optimistic UI — неправильный инструмент, и что делать вместо него?
Async-бэкенды переносят окно консистентности — разрыв между «принято» и «читаемо» — на фронтенд, и наивный UI либо врёт, либо показывает устаревшие данные. Два честных инструмента делятся по предсказуемости: когда клиент может предсказать результат, используй optimistic UI — примени локально, сделай снимок, отправь, затем согласуй с серверной истиной на settle или откати при ошибке — что покупает ~0мс воспринимаемой задержки против порогов восприятия 100мс/1с/10с. Когда исход решает сервер, покажи явное состояние ожидания с таймаутом и согласованием, чтобы не приходящее событие не крутилось вечно, и никогда не схлопывай 202 Accepted в тост успеха. Read-your-own-writes ломается, когда рефетч гонится с консьюмером, поэтому эхай изменение локально вместо доверия немедленному гоночному чтению. Делай повторы идемпотентными через ключ, чтобы двойной клик не списал дважды. А когда сталкиваются две async-записи, выбирай согласование осознанно — last-write-wins для простых полей (принимая, что он отбрасывает проигравшего и доверяет часам), CRDT для общих документов — и затем делай исход читаемым для пользователя вместо молчаливой перезаписи его работы.