open atlas
↑ К треку
Очереди, потоки, события QUE · 07 · 01

UX поверх async-бэкендов: optimistic UI, состояния ожидания, read-your-writes

Когда запись идёт через очередь, результат не читается мгновенно. Наивный UI либо врёт («готово!» до того, как пайплайн закончил), либо показывает устаревшие данные. Это фронтенд-половина event-driven систем.

QUE Junior ◷ 17 min
Уровень
ОсновыJuniorMiddleSenior
Уже знаешь этот юнит? Пройди быструю проверку за минуту →

Пользователь жмёт «Опубликовать», спиннер останавливается, тост говорит «Опубликовано!» — а поста нигде нет. Тикеты в саппорт растут. Баг: 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мс, рендеря предсказанный результат сразу.

У контракта три части, и третью джуны пропускают:

  1. Применить предсказанное изменение к локальному состоянию и показать его.
  2. Отправить запрос; сохранить достаточно контекста, чтобы откатить.
  3. Согласовать — при успехе заменить предсказание серверной истиной (она может отличаться); при ошибке откатить к снимку, который ты взял перед применением.

Вместе эти три шага означают, что UI никогда не застревает с устаревшим предсказанием: либо сервер подтверждает и ты заменяешь на правду, либо отклоняет и ты откатываешь, как будто ничего не было. Пропусти шаг 3 — и ты отгружаешь ложь: оптимистичное значение висит на экране без пути коррекции.

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. 1 onMutate: отменить рефетчи в полёте, чтобы они не затёрли оптимистичное значение
  2. 2 onMutate: сделать снимок текущего кэша (чтобы можно было откатить)
  3. 3 onMutate: записать предсказанное значение в кэш и вернуть снимок как контекст
  4. 4 onError: восстановить снимок из контекста — откатить предсказание
  5. 5 onSettled: invalidateQueries, чтобы согласоваться с реальным состоянием сервера
Вспомните перед уходом
  1. 01
    Почему «POST, потом сразу рефетч» ломает read-your-own-writes на async-бэкенде, и как это починить, не отказываясь от обновления?
  2. 02
    Когда optimistic UI — неправильный инструмент, и что делать вместо него?
Итог

Async-бэкенды переносят окно консистентности — разрыв между «принято» и «читаемо» — на фронтенд, и наивный UI либо врёт, либо показывает устаревшие данные. Два честных инструмента делятся по предсказуемости: когда клиент может предсказать результат, используй optimistic UI — примени локально, сделай снимок, отправь, затем согласуй с серверной истиной на settle или откати при ошибке — что покупает ~0мс воспринимаемой задержки против порогов восприятия 100мс/1с/10с. Когда исход решает сервер, покажи явное состояние ожидания с таймаутом и согласованием, чтобы не приходящее событие не крутилось вечно, и никогда не схлопывай 202 Accepted в тост успеха. Read-your-own-writes ломается, когда рефетч гонится с консьюмером, поэтому эхай изменение локально вместо доверия немедленному гоночному чтению. Делай повторы идемпотентными через ключ, чтобы двойной клик не списал дважды. А когда сталкиваются две async-записи, выбирай согласование осознанно — last-write-wins для простых полей (принимая, что он отбрасывает проигравшего и доверяет часам), CRDT для общих документов — и затем делай исход читаемым для пользователя вместо молчаливой перезаписи его работы. Теперь, когда встретишь тост «Опубликовано!» на 202 — ты знаешь: это ложь в процессе, и знаешь ровно те контракты, которые её исправляют.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 6 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources4
expand
  1. 01
  2. 02
  3. 03
  4. 04

Trademarks belong to their respective owners. Editorial reference only.