Почему мы убрали оптимистичные апдейты из планировщика

14 июля 2026 · фронтенд

интерфейс состояние TypeScript

Мы два года считали, что мгновенная реакция интерфейса — это по определению хорошо. Потом посчитали, как часто эта мгновенная реакция оказывается неправдой, и убрали её из переноса встреч.

Как это было устроено

Перенос встречи в календаре — перетаскивание карточки на другой слот. Обработчик применял изменение к локальному состоянию сразу, а запрос уходил следом. Классика:

function moveMeeting(id: MeetingId, target: Slot): void {
  const before = store.meeting(id).slot;

  // Мгновенно: карточка уже на новом месте.
  store.apply({ type: 'meeting/moved', id, slot: target });

  api.move(id, target).catch((err) => {
    // Через 300–900 мс: карточка прыгает обратно.
    store.apply({ type: 'meeting/moved', id, slot: before });
    toast(describe(err));
  });
}

Пока сервер отвечал «да», всё было прекрасно. Вопрос в том, как часто он отвечал «нет».

Четыре процента — это много

Мы добавили метрику на исход операции и собрали данные за март–апрель. Из 12 400 переносов встреч 510 (4,1%) закончились откатом. Разбивка по причинам:

Причина отказаДоля откатовСобытий
Переговорка занята (конфликт брони)61%311
Встречу параллельно правил кто-то другой18%92
Нет прав на чужую повторяющуюся встречу13%66
Сеть и таймауты8%41

Четыре процента звучит немного, пока не переведёшь в людей: это примерно каждый двадцать пятый перенос и 23 обращения в поддержку за квартал с формулировкой вида «я перенёс встречу, а она вернулась обратно, и я не понял, сохранилось или нет». Причём главная причина — конфликт брони — принципиально не предсказуема на клиенте: комнату мог занять другой человек полсекунды назад.

Три отката, которые не получилось сделать незаметными

Мы честно пытались спасти оптимизм. Не вышло по трём причинам, и каждая из них — не про анимацию, а про смысл.

  • Откат виден, потому что он поздний. p95 ответа сервера на тот момент был 740 мс. За это время человек успевает перевести взгляд, начать следующее действие и не связать прыжок карточки со своим прошлым жестом. Мы пробовали подсветку и анимацию возврата — стало нарядно, но не понятнее.
  • Откат ломает соседние действия. Перетащил встречу на 15:00, сразу перетащил вторую на 16:00, первая откатилась — и вторая теперь стоит относительно состояния, которого нет. Мы завели стек снимков и корректно раскручивали его в обратном порядке; кода стало заметно больше, а число веток, которые надо тестировать, выросло примерно вдвое.
  • Оптимизм врал не только владельцу. Карточка «переехала» в интерфейсе того, кто тянул, но у остальных участников встречи ничего не менялось — они видели правду. То есть на пару секунд у двух людей на соседних мониторах был разный календарь, и правым оказывался тот, кто ничего не делал.

Что сделали вместо

Две временные шкалы: сверху оптимистичный сценарий, где интерфейс показывает перенос сразу после клика, а через 740 мс приходит отказ и карточка возвращается; снизу новый сценарий, где кнопка занята до ответа сервера через 240 мс, после чего состояние становится окончательным
Один переход состояния вместо двух. Пользователь ждёт немного дольше, но видит результат ровно один раз.

Состояние операции стало явным и конечным. Никаких снимков и раскруток — три варианта и всё:

type MoveState =
  | { kind: 'idle' }
  | { kind: 'pending'; from: Slot; to: Slot; token: string }
  | { kind: 'failed'; from: Slot; reason: MoveError };

// Индикатор занятости показываем не сразу: если сервер ответил
// за 120 мс, мерцание спиннера раздражает сильнее ожидания.
const SPINNER_DELAY_MS = 120;

async function moveMeeting(id: MeetingId, target: Slot): Promise<void> {
  const from = store.meeting(id).slot;
  // Один и тот же token при повторе — сервер не создаст вторую правку.
  const token = idempotencyToken(id, from, target);

  store.setMove(id, { kind: 'pending', from, to: target, token });
  const spinner = setTimeout(() => store.showBusy(id), SPINNER_DELAY_MS);

  try {
    const applied = await api.move(id, target, token);
    // Пишем то, что вернул сервер, а не то, что мы просили.
    store.commit(applied);
  } catch (err) {
    store.setMove(id, { kind: 'failed', from, reason: toMoveError(err) });
  } finally {
    clearTimeout(spinner);
  }
}

Пока операция в pending, целевой слот подсвечен, карточка остаётся на старом месте, а повторное перетаскивание той же встречи заблокировано. При failed мы показываем не тост, а сообщение прямо на карточке — с причиной и, для самого частого случая, с двумя ближайшими свободными слотами этой комнаты.

Пришлось чинить сервер

Честное ожидание работает только тогда, когда ждать недолго. С p95 в 740 мс новый вариант ощущался бы хуже старого, поэтому половина работы была не во фронтенде.

  • Убрали из обработчика переноса синхронную рассылку уведомлений — теперь она уходит в очередь, о которой мы писали в мае. Минус 210 мс.
  • Схлопнули три последовательных запроса к базе (встреча, участники, бронь) в один с двумя join. Минус 160 мс.
  • Проверку конфликта отдали ограничению исключения в PostgreSQL вместо чтения-проверки-записи — заодно ушла гонка, из-за которой возникали 18% откатов из таблицы выше. Об этом есть отдельная заметка.

Итог: p95 переноса 240 мс, p99 — 410 мс. На этих числах ожидание перестаёт читаться как задержка.

Где оптимизм остался

Мы не выбрасывали подход целиком — это был бы карго-культ наоборот. Оптимистичный апдейт остался там, где он безопасен по устройству операции:

  • Отметка задачи выполненной. Отказать может только сеть; конфликтов по смыслу нет, повторная отметка идемпотентна.
  • Раскрытие и сворачивание блоков, локальные фильтры, порядок колонок. Это состояние интерфейса, серверу оно вообще не принадлежит.
  • Черновик описания встречи. Текст в поле — уже правда, вопрос только в моменте сохранения.

Правило, которое мы из этого вывели и записали в свод соглашений команды: оптимистично применяем только те операции, отказ по которым не зависит от состояния на сервере. Если сервер может сказать «нет» по причине, о которой клиент не мог знать, — ждём ответ.

Что изменилось в цифрах

МетрикаДоПосле
p95 операции переноса740 мс240 мс
Видимых откатов на 1 000 переносов410
Обращений «встреча вернулась назад»23 за квартал0 за квартал
Повторных попыток переноса подряд7,8%2,1%
Строк кода в модуле переноса1 180780

Последняя строка — приятный побочный эффект: исчезли снимки состояния, стек отката и ветки «а что если откатилось то, поверх чего мы уже применили другое». Код стал не только короче, но и понятнее в чтении, что для модуля с двумя дежурными правками в квартал важнее пары сэкономленных килобайт.