Real-Time Backend

Collaborative Cursors

Figma показывает курсоры 100+ участников одновременно без тормозов. Notion синхронизирует выделения в реальном времени. Это не магия - это awareness protocol с правильным throttling.

  • **Figma** при >50 онлайн-пользователях автоматически переключается с 50ms на 200ms throttle для курсоров - пользователи не замечают, зато сервер не захлёбывается
  • **Google Docs** использует RelativePosition для выделений: если коллега вставляет текст перед твоим выделением - оно автоматически смещается без рассинхронизации
  • **Linear.app** показывает аватары онлайн-участников через awareness snapshot: новый подключившийся сразу видит всех присутствующих, без ожидания их следующего обновления
  • **Notion** использует debounce 100ms для selection sync: промежуточные состояния при drag-выделении не передаются, только финальный результат

Collab Cursors

В Google Docs одновременно работают тысячи пользователей - каждый видит цветной курсор коллеги в реальном времени. Это не просто косметика: **collaborative cursors** - отдельный слой поверх document CRDT, называемый awareness (осведомлённость). Он передаёт эфемерное состояние пользователя: позицию курсора, выделение, имя, цвет - данные, которые не нужно сохранять в истории документа.

Ключевое отличие от document state: awareness данные не требуют CRDT-слияния и не персистятся. Если пользователь отключился - его курсор просто исчезает. Это делает архитектуру проще: сервер работает как broadcast-хаб, а не как хранилище.

**Yjs awareness protocol** - стандарт де-факто: каждый клиент имеет clientId (uint32), хранит Map<clientId, state>. При изменении своего state - инкрементирует clock и рассылает {clientId, clock, state}. Получатель обновляет только если входящий clock > сохранённого. TTL для мёртвых курсоров - обычно 30 секунд.

Почему awareness-данные (курсоры, выделения) не хранятся в CRDT-истории документа?

Awareness Protocol

Awareness protocol решает задачу eventual consistency для эфемерного состояния. В отличие от CRDT-документа, здесь нет истории операций - есть только последнее состояние каждого участника. Протокол строится на векторных часах уровня клиента: каждое обновление несёт монотонно растущий счётчик.

**Snapshot при подключении** - критично: новый участник должен сразу увидеть всех присутствующих. Сервер отправляет текущий Map целиком одним сообщением, а не ждёт следующего обновления от каждого клиента. Linear Networks (создатели Linear.app) используют именно этот подход для отображения аватаров онлайн-коллег.

Клиент A отправил awareness с clock=5, затем из-за сетевого сбоя пришло дублирующее сообщение с clock=3. Что должен сделать сервер?

Throttling

Движение мыши генерирует до 60 событий в секунду. Отправлять каждое в WebSocket - значит заваливать сервер 60 сообщениями/сек на пользователя. При 100 пользователях в документе - 6000 сообщений/сек только на курсоры. Throttling (дросселирование) ограничивает частоту отправки без потери UX.

**Числа из продакшна:** Notion использует throttle 100ms для cursor (10 updates/sec). Figma - 50ms (20/sec) с adaptive throttling: при >50 участниках автоматически снижается до 200ms. Google Docs - около 80ms. Баланс: <100ms задержка воспринимается как real-time, >300ms - как заметный lag.

Когда лучше использовать debounce вместо throttle для синхронизации состояния?

Selection Sync

Синхронизация выделения сложнее курсора: выделение привязано не к пикселям экрана, а к **позициям в документе** - индексам символов или якорям в дереве. При одновременном редактировании позиции смещаются из-за вставок/удалений - и сохранённое выделение становится невалидным.

Решение - хранить выделение в терминах CRDT-идентификаторов, а не абсолютных смещений. В Yjs это `RelativePosition` - позиция относительно конкретного символа-якоря, которая автоматически обновляется при редактировании.

**Граничный случай:** если символ-якорь удалён другим пользователем - `createAbsolutePositionFromRelativePosition` возвращает null. Клиент должен обработать это: обычно выделение просто сбрасывается. Поэтому в Google Docs при удалении выделенного другим пользователем текста курсор коллеги схлопывается в точку.

Cursor sync и selection sync - одна и та же задача, достаточно хранить {x, y} координаты

Курсор в collaborative editor - позиция в документе (CRDT-якорь), а не пиксели. Пиксели зависят от layout, zoom, размера окна каждого пользователя

Два пользователя могут видеть документ при разном масштабе. Пиксельная позиция курсора Алисы на её экране не соответствует никакой осмысленной позиции на экране Боба. Только документная позиция (индекс символа) универсальна.

Пользователь A выделил символы с 10 по 20. Пользователь B вставил 5 символов в позицию 5. Что произойдёт с абсолютными индексами выделения A?

Итоги

  • Awareness - отдельный слой от CRDT-документа: эфемерное состояние с LWW по логическому clock, без персистентности
  • Throttle (50-100ms) для cursor position, debounce для selection - разные паттерны под разные требования к актуальности
  • Выделение хранится в CRDT-якорях (RelativePosition), а не абсолютных смещениях - иначе сдвигается при чужих вставках

Связанные темы

Collaborative cursors опираются на несколько смежных концепций:

  • Yjs CRDT — Awareness-протокол встроен в Yjs как отдельный класс поверх Y.Doc; RelativePosition - часть Yjs API
  • WebSocket broadcast — Awareness updates передаются через тот же WebSocket канал что и doc-операции, но с отдельным message type
  • Selection Sync — Финальная концепция этого урока - применение awareness для синхронизации выделений текста

Вопросы для размышления

  • Figma показывает имя пользователя рядом с его курсором только первые 2 секунды, затем скрывает. Почему это правильное UX-решение при большом числе участников?
  • Как бы реализовать 'inactive cursor fade' - постепенное исчезновение курсора пользователя, который не двигал мышь 30 секунд?
  • При чём здесь TTL и как сервер должен чистить мёртвые awareness-состояния отключившихся клиентов?

Связанные уроки

  • net-01-intro
Collaborative Cursors

0

1

Войти