Thread Virtualization

Render very long threads with @tanstack/react-virtual and ThreadPrimitive.MessageByIndex.

Virtualization mounts only the messages near the viewport and represents the rest as empty space, so a thread with thousands of messages scrolls like one with twenty. assistant-ui does not ship a virtualized thread component; this guide shows the supported composition, extracted from a production consumer and available as a runnable example: examples/with-virtualized-thread.

Do you need this?

Probably not. The default kit already renders message bodies with content-visibility: auto and contain-intrinsic-size, which skips paint work for off-screen messages, and ThreadPrimitive.Viewport handles auto-scroll. That covers typical threads. Reach for virtualization when React mount and update cost itself becomes the bottleneck: threads with hundreds to thousands of messages, or very heavy per-message content, where typing latency degrades because every message stays mounted.

Rendering messages by index

ThreadPrimitive.MessageByIndex renders a single message at a given index and memoizes on the index plus per-field components identity. Pass a module-level components object so the memo holds and a streaming delta only re-renders the message it belongs to:

const MESSAGE_COMPONENTS = { UserMessage, AssistantMessage };

<ThreadPrimitive.MessageByIndex index={index} components={MESSAGE_COMPONENTS} />;

Grouping into turns

Virtualizing per user turn (a user message plus the responses that follow it) gives the virtualizer stable, meaningfully sized items. Subscribe to a single signature string so the selector returns a primitive and the turn array only rebuilds when membership actually changes:

const signature = useAuiState((s) =>
  s.thread.messages.map((m, i) => `${i}:${m.role}:${m.id}`).join("\n"),
);
const turns = useMemo(() => buildTurns(signature), [signature]);

Padding spacers, not absolute positioning

Render the virtual items in normal document flow inside a spacer div whose paddingTop/paddingBottom represent the unmounted regions. Items remain regular flow children, so message CSS (including position: sticky patterns and the kit styling) keeps working, and virtualizer.measureElement records real heights as items mount:

const items = virtualizer.getVirtualItems();
const paddingTop = items[0]?.start ?? 0;
const paddingBottom = Math.max(
  0,
  virtualizer.getTotalSize() - (items.at(-1)?.end ?? 0),
);

Owning the scroll element

The composition owns its scroll container instead of using ThreadPrimitive.Viewport: the built-in auto-scroll assumes every message is mounted, and its resize-driven re-pin can fight the virtualizer's measurement adjustments. Three pieces replace it:

  1. Auto-follow. A ResizeObserver on the content wrapper re-pins the scroller to the bottom while a sticky flag is armed. The flag disarms when the user scrolls up (detected as scrollTop decreasing while scrollHeight and clientHeight are stable, the same heuristic the built-in viewport uses, plus wheel-up and touchmove) and re-arms when the user returns to the bottom.
  2. Measurement guard. While pinned at the bottom, the virtualizer's own scroll adjustments on item re-measurement are suppressed via a custom scrollToFn; without this the two scroll writers fight and the view rubber-bands during streaming.
  3. Run-start jump. A useLayoutEffect observes s.thread.isRunning flipping to true and jumps to the bottom before paint, so a just-sent message never flashes below the fold. The thread.runStart event is deprecated; deriving the transition from state is the supported path.

The production consumer this is extracted from disables streaming auto-follow entirely as a product choice; the example keeps following because it matches ThreadPrimitive.Viewport's default behavior. Both are valid, and the disarm guard makes either safe.

Try it

npx assistant-ui@latest create my-app

Then copy app/VirtualizedThread.tsx, app/MyRuntimeProvider.tsx, and app/seed-messages.ts from the example, or clone the repo and run pnpm --filter with-virtualized-thread dev.