Skip to content
GuidesPerformanceMenu

Performance

Streaming without layout shift, keeping long threads fast, lazy Shiki, KaTeX and Mermaid, measured bundle sizes per item, and notes on the React Compiler.

A chat interface re-renders dozens of times a second while a reply streams, and the page keeps every earlier reply on screen. chatcn is built so that work stays proportional to what changed. This guide covers what the components do and what your app should do around them.

Streaming without layout shift

  • Markdown. Finished blocks keep their props and never render again. Code block chrome appears with the opening fence and highlighting swaps in on the same text, so lines never move. Unfinished bold, links and code heal instead of flashing raw syntax. The Markdown demo streams with a cumulative layout shift under 0.001.
  • Math and diagrams. Display math reserves the height of a one-line formula while it streams, and KaTeX fonts load before the first render. A Mermaid fence shows its source in place until the diagram is ready.
  • Images. Pass width and height (or aspectRatio) to Image and the frame holds its size before the file arrives.
  • Charts and widgets. Pass expectedCount and domain to Chart while points stream, so the axis never rescales. Generative UI widgets receive streaming and reserve their final size.
  • Pending state. ThinkingIndicator shows nothing for 300 ms and then stays at least 400 ms, so fast replies never flash a loader.
  • Scrolling. Conversation scrolls a new user turn near the top and puts a spacer below it, so the reply streams into empty space instead of pushing the turn away. Content added above the viewport keeps your reading position.
  • Composer. Send becomes Stop in the same footprint, so nothing around it moves.

Long threads

AI SDK replaces only the message being streamed; every earlier message keeps its object identity between updates. Render each message in a memoized component and earlier turns skip rendering entirely while the newest one streams.

tsx
import { memo } from "react"
import { Markdown } from "@/components/ui/chatcn/markdown"
import { Message, MessageContent } from "@/components/ui/chatcn/message"
import { MessageParts } from "@/components/ui/chatcn/message-parts"
import type { ChatcnMessage } from "@/lib/chatcn-types"

// Earlier messages keep their identity, so memo skips them while the last one streams.
export const ThreadMessage = memo(function ThreadMessage({
  message,
  streaming,
}: {
  message: ChatcnMessage
  streaming: boolean
}) {
  return (
    <Message from={message.role} state={streaming ? "streaming" : "done"}>
      <MessageContent>
        <MessageParts
          message={message}
          streaming={streaming}
          renderText={(text, ctx) => <Markdown streaming={ctx.streaming}>{text}</Markdown>}
        />
      </MessageContent>
    </Message>
  )
})
  • Keep renderer maps and part component maps at module scope. A new map on every render gives every part a new component identity and remounts it.
  • Throttle updates when tokens arrive faster than the screen can use them: useChatcnChat({ transport, throttle: 50 }) passes throttle to useChat.
  • For threads with hundreds of turns, load older history on demand and prepend it. Conversation keeps the reading position when content is inserted above.
  • The blocks derive their side panels from the messages on each render (codingWorkspaceFromMessages is pure and linear in the number of parts). That is cheap for real sessions; if you run thousands of tool calls in one thread, memoize it on the messages array.

Heavy dependencies load on first use

LibraryLoads whenHow
ShikiThe first code block on the page asks for a languageOne shared instance. Core, the JavaScript regex engine (no WebAssembly) and each grammar are separate chunks, fetched once per page. After that, highlighting is synchronous, so streaming code highlights on the frame it arrives
KaTeXAn answer contains mathImported once, fonts loaded before the first formula renders
MermaidAn answer contains a mermaid fenceImported once and rendered when the fence closes

None of the three is in the initial bundle of any component. A page whose answers never contain code, math or diagrams never downloads them.

Bundle size per item

Measured with node packages/registry-tools/src/size.mjs: each item is bundled from its own entry, minified and gzipped, with React external. Own is the item's files alone. +npm adds the npm packages it imports. Total is everything an install loads up front, including other chatcn items it depends on. Lazy is code behind dynamic imports, the most that could load on demand.

ItemOwn+npmTotalLazy
button0.7 kB0.7 kB10.9 kB
conversation0.9 kB0.9 kB13.0 kB
streaming-text0.5 kB0.5 kB11.6 kB
message2.6 kB2.6 kB51.0 kB
message-parts2.3 kB8.5 kB34.5 kB
tool-call4.3 kB10.3 kB32.5 kB
reasoning2.1 kB8.3 kB28.8 kB
prompt-input2.8 kB2.8 kB54.3 kB
code-block2.6 kB12.1 kB60.4 kB1,297.1 kB
math1.6 kB1.6 kB50.3 kB74.8 kB
mermaid3.8 kB3.8 kB61.6 kB1,400.0 kB
markdown4.0 kB152.4 kB238.0 kB2,792.3 kB
diff-viewer5.5 kB5.5 kB58.0 kB
terminal3.9 kB3.9 kB54.3 kB
chart8.1 kB8.1 kB56.6 kB
rich-prompt-input5.5 kB99.2 kB128.1 kB
chatcn-genui5.0 kB31.5 kB104.1 kB
coding-agent7.0 kB7.0 kB299.6 kB2,792.3 kB
use-chatcn-chat0.4 kB45.8 kB46.1 kB
chatcn-mock-transport1.7 kB1.7 kB3.5 kB
use-chatcn-ag-ui1.8 kB1.8 kB6.0 kB
use-chatcn-langgraph1.3 kB1.3 kB5.0 kB
chatcn-icons11.1 kB11.1 kB11.1 kB
  • Leaf components stay under 5 kB of their own code. The few above it (chart, diff-viewer, rich-prompt-input) draw without a chart library, parse diffs and run Tiptap respectively.
  • Lazy sizes count every Shiki grammar. A page loads only the grammars its code blocks use, typically a few kB each.
  • Totals overlap. markdown brings code-block, math and mermaid; installing all four costs the markdown total once, not four times.
  • chatcn-icons is one module with named exports. Bundlers keep only the icons your components import.

React Compiler

  • chatcn does not require the compiler and its tests run without it. Components work the same with it on.
  • Components avoid mutating values during render and keep hooks unconditional, so most of them compile.
  • A few hooks, useChatcnChat among them, write the latest value into a ref during render so their callbacks stay stable. The compiler skips optimizing a function that does this and leaves it as written, which keeps behavior the same.
  • The handful of explicit React.memo wrappers (code block, diff viewer, terminal, ANSI output, file tree) are harmless under the compiler. Leave them in.