← Back to overview
Read as Historical Evidence

Generic offline-mutation-queue and CDC/replay client-sync material below describes TRW. Boja uses Rocicorp Zero for synced reactive data and an app-owned durable message-send outbox. CDC/event-outbox lessons apply to server-side effects such as search, audit, notifications and jobs.

Source: /Users/raynor/dev/trw (Bun/TypeScript monorepo). Investigated 2026-07-16. Primary references: ARCHITECTURE.md, docs/CHAT-CONTEXT-PACK.md, app/AGENTS.md, plus direct code reads cited inline.

0. System Topology

Chat spans six runtimes:

LayerRuntimeOwns
UIapp/ (React 19, TanStack Router/Query, Jotai, legacy MobX)Message list, input, embeds, uploads
Transportapp/src/client.js/WebSocket client, MobX entity maps (Users/Channels/Messages/Members)
HTTP APIeden/ (Elysia on Bun)messages, channels, reactions, search, unreads
Realtime fanoutnile/ (Bun WS at /live/ws)sessions, topic subs, auth, replay, presence
Change streamssink/Mongo change streams → EventV2, Meilisearch indexing, live cache
Background jobsserf/ (BullMQ Pro)notifications, unreads batching, embed unfurl, scheduled sends

Key architectural invariant: write handlers never emit realtime events directly — writes go through models/context/* helpers, and fanout is either explicit pubsub from serf workers (chat/EventV1) or derived from Mongo change streams in sink (EventV2). Chat uses EventV1 only; EventV2 is job-portal/video-calls.

The frontend chat data plane is a four-layer sandwich: TanStack Query infinite cache → MobX entity maps (appClient) → Jotai mirror (app/src/utils/atomWithQueryData.ts) → derived Jotai selectors (messagesState.ts) → an imperative runtime (chatRuntime.ts). This layering is the single biggest source of complexity.

1. Message Data Model

Collections (models/)

Indexes

Denormalization inventory

Denormalized dataWhereReconciliation
reaction_counts Map on messagemodels/Message.tsMessageReactionCount delta events; optimistic client increments
last_message_id / first_message_id / active on channelmodels/Channel.tsmessagePublishTask post-send; delete-time repair findOne
Hydrated users/members in message query responsesmodels/Message.ts (60s Redis hydration cache)TTL only — schema changes within 60s can serve stale hydration
Message search documentsMeilisearch via sink/src/streams/messageSearchStream.ts (batch 100 docs / 30s flush)change-stream driven, resume token sink:search_sync_resume_token
Legacy NaN timestampsrepaired on read via repairMessageTimestampsdecodeTime(sort_id)permanent read-path band-aid
Lesson

ULID _id + separate sort_id gives you time-ordered ids that double as cursors — this works well. Denormalized channel tail pointers (last_message_id) are load-bearing for unreads, entry planning, and pagination termination; a sync engine should make them first-class server-maintained fields.

2. Infinite-Scroll Pagination

Server side (eden/src/routers/messagesRouter.tsmodels/Message.ts queryMessages)

Single bulk endpoint POST /messages/query supports: before, after, before_sorted, after_sorted, nearby, ids, sort (Latest/Oldest), limit (capped 100), throw_on_not_found, has_attachment_types, has_link.

Client side (app/src/modules/Chat/ChatMessageList/messagesApi.ts)

TanStack useInfiniteQuery with a typed page-param discriminated union MessageFetchAction = {type:"top"|"bottom"|"nearby", target?, target_sorted?}:

Bidirectional scroll + anchoring (ChatV4.tsx)

Lesson

Bidirectional infinite scroll over a reverse virtualized list works, but requires ~5 dedicated correction mechanisms (shift flag, settle loop, drift buffer, bottom threshold, prepend layout-effect). In a sync-engine design, keep the cursor model (sort_id ranges + nearby stitching + short-page boundary detection + present-tail tracking) — it's genuinely good — but pick/build a virtualizer with first-class reverse-list anchoring.

3. Replies and Threading

Flat replies (Discord-style, primary)

Threads (newer, channel-backed)

Lesson

Replies-as-degenerate-array is confusing schema debt; model reply_to as a scalar. Threads-as-channels is a solid choice (reuses pagination/unreads/permissions wholesale) but the related_* hydration side-channel shows the cost of not having a normalized client store — a sync engine with proper entity normalization gets this for free.

4. Jump-to-Message (Permalinks, Old History, Window Re-establishment)

Lesson

The nearby fetch + synthetic-cursor fallback is the right server primitive. The pain is client-side: because the window is a single TanStack infinite query per channel, a far jump requires destroying the whole window and re-seeding it. A sync engine with a local message store indexed by sort_id could re-window locally without a destructive reset.

5. Unread Tracking / Read States

Storage: models/UserUnreads.ts — sharded

Write path

Read path / client

Lesson

Server-side read-state with per-channel last_id + mention id list is simple and works, but the async batched write path means read-state is eventually consistent with real UX lag. The 8-shard hack is a scar from unbounded per-user maps. In a rewrite: model read state as a first-class synced entity per (user, channel) row, update it locally-first, and let the sync engine reconcile — no batching pipeline needed.

6. Frontend Resumability & Caching

Caching layers

  1. TanStack Query infinite cache per channel (staleTime: Infinity, gcTime: 60min) — messages never go stale by time.
  2. MobX entity maps (app/src/client.js/maps/Messages.ts, Users.ts, Channels.ts, Members.ts) — the legacy entity cache shared with the websocket layer. Every fetched page is hydrated into MobX and stored in TanStack pages (dual write).
  3. Jotai mirror (app/src/utils/atomWithQueryData.ts) subscribes to TanStack cache events and mirrors into messagesAtomFamily → derived messageItemsAtomFamily (renderable items).
  4. Drafts persisted to IndexedDB via persistStore (chatState.ts messageDraftByChannelFamily — migrated out of localStorage for space).

React <Activity/> offscreen rendering

Runtime suspension (channel switch)

Reconnect / tab switch

Lesson

TRW effectively hand-built a partial sync engine: replay cursors, entity maps, offscreen keep-alive, resumable runtimes. The <Activity/> pool + per-channel runtime registry is the strongest idea here — instant channel switching with warm state. The weakness is that cache validity is binary (replay-or-nuke): a missed replay window throws away everything instead of diffing. A real sync engine with server-authoritative versions per channel removes the nuke path.

7. Optimistic Sends, Retries, Dedupe

Send flow (app/src/modules/Chat/ChatFooter/useSendMessage.ts + chatOutbox/):

  1. Draft merged; client generates _id = clockDriftedUlid() and a separate nonce ULID.
  2. Optimistic message created via appClient.messages.createObj and appended to the query cache immediately; uploads awaited first.
  3. Entry pushed into the outbox (chatState.ts: outboxAtom), processed by chatOutbox/outboxProcessor.ts:
    • In-flight set prevents a WS disconnect/reconnect cycle from firing a duplicate HTTP send for the same optimistic id.
    • Offline pause: entries move to paused when WS is disconnected, rescheduled on connected.
    • Error classification (outboxErrorClassifier.ts): retryable = network failure / timeout / 502-503-504 → infinite retry with exponential backoff; terminal = SlowMode, TimedOut, 4xx/429, and unknown errors default terminal ("avoids infinite loops on logic bugs") → failed state + toast.
    • Retry id refresh: on retry, the optimistic message is re-registered under a fresh ULID so its timestamp stays current — display-only, because "idempotency on the server is protected by nonce."
  4. Server: POST /:channelId/messagesmessagesContext.sendMessage (validation, cooldown, anti-spam 30s slowmode >60msg/min, attachments, mentions caps) → insert → messagePublishTaskMessageWithUser fanout.
  5. Dedupe on echo: messagesApi.appendChannelMessage matches by nonce first, _id second and replaces the optimistic entry in-place rather than double-appending.

Caveat: the server-side channel + nonce unique index is commented out, so true server-side idempotency depends on soft checks; a rapid retry racing a slow success could theoretically double-insert.

Lesson

The message-send outbox design (state machine per entry: sending/paused/retrying/failed, error classification, nonce dedupe, in-flight guard) is exactly the right shape for the selected message-only durable outbox. Enforce nonce uniqueness in the DB and make this outbox durable (it is in-memory Jotai today; a page refresh loses queued sends even though drafts persist to IndexedDB). Do not generalize it into an offline queue for other mutations.

8. Attachments / Media

Lesson

Attachments-as-entities with server-side variant generation, blurhash placeholders, and upload-before-send is all solid — keep the shape. The late-arriving embed/MessageAppend mutation is one of the things that breaks scroll anchoring (content grows after settle — APP-3067); a rewrite should reserve layout (aspect-ratio boxes from metadata) before media/embeds resolve.

9. Known Pain Points

  1. Five-layer state plumbing. TanStack Query pages + MobX entity maps + Jotai mirror + derived atom families + imperative ChatViewRuntime. The context pack warns: "Cache reset and atom reset must stay aligned... Always pair messagesApi.destroyMessagesCache + resetAllMessagesState". MobX is officially a legacy boundary no new feature may extend.
  2. Jump = local reload. Permalink outside the loaded slice destroys and rebuilds the entire window. Called "Expensive but correct" internally.
  3. Scroll jank management is a subsystem of its own. settleAtBottom.ts rAF loop (3-stable-frame heuristic + 1.5s safety timeout), isPrependRef/shift dance, BOTTOM_THRESHOLD_PX=5, POST_PRESENT_SCROLL_DRIFT_BUFFER_PX hysteresis, and the APP-3067 fix in resolveFollowPresentOnScroll.ts.
  4. Boot watchdog as loading-screen band-aid. 5s timeout → destroy cache and retry → second timeout → error phase. The loading curtain needs an explicit shouldBypassResumeLoading escape hatch.
  5. Resync = refetch storm. On missed replay window: evict all pooled hidden channels + restart_at_present + invalidate "major query keys". Every warm channel becomes a cold boot at once.
  6. Missing DB idempotency indexchannel + nonce unique index commented out.
  7. Read-path repairs: NaN-timestamp repair on every read; replies[] array where only element 0 counts; legacy ChannelUnread beside sharded UserUnreads.
  8. 60s Redis hydration cache for users/members in message responses can serve stale author data after deployments.
  9. DOM node contention — hover focus, popup targeting, swipe transforms, reply-arrow portal, and highlight styling all target the same message row DOM; changing DOM structure in ChatMessage.tsx can break multiple interaction subsystems at once.
  10. Multi-hop realtime chain fragilitymodels write → serf/sink → Redis pubsub → nile → WS → client; a failure in any link breaks live updates without necessarily failing the originating request.
  11. Unreads eventual consistency + shard hack — batching pipeline exists purely to survive write volume of a chatty schema.
  12. Assorted in-code TODOs: menu-flicker setTimeout(..., 1) hack, embed addon needing "a better way to get server & channel", kick error swallowed in finally.
  13. In-memory outbox — queued/failed sends don't survive refresh (drafts do, via IndexedDB).

10. What to Keep / What to Avoid in a Rewrite

Keep (proven, well-shaped ideas)

Avoid (the scar tissue)


Files most worth reading before designing the replacement: docs/CHAT-CONTEXT-PACK.md (1,434 lines, excellent), app/src/modules/Chat/ChatMessageList/messagesApi.ts, app/src/modules/Chat/ChatV3/chatRuntime.ts, app/src/modules/Chat/chatOutbox/outboxProcessor.ts, models/Message.ts, models/UserUnreads.ts, app/src/modules/Chat/ChatChannelActivityPool.tsx.