DeltaKitDeltaKit

StreamingMarkdown

How block-level incremental rendering works.

The <StreamingMarkdown /> component renders markdown incrementally at the block level. Settled blocks are memoized so only the active block normally re-renders as new tokens arrive.

How It Works

As markdown streams in, the parser splits content into blocks — headings, paragraphs, code blocks, lists, blockquotes, and so on.

Stream: "# Hello\n\nThis is a paragraph being streamed tok"

Stable blocks (normally do not re-render):
  [0] <h1>Hello</h1>              <- complete, frozen

Active block (re-renders on each token):
  [1] <p>This is a paragraph being streamed tok</p>

Each block gets a stable React key. When a new block starts, the previous one is treated as settled and wrapped in React.memo with a comparator that skips unchanged blocks. If later parsing extends the same block because the stream was still ambiguous, that block can still update.

Block Completion Rules

A block is considered complete when its terminator is received:

Block TypeStarts WithComplete When
Heading# , ## , etc.Newline received
ParagraphAny textBlank line (\n\n) received
Code block``` or ~~~Closing fence received
Blockquote> Blank line received
List- , * , 1. Blank line received
Table|Row without | received
Horizontal rule--- / ***Newline received

Code Block Handling

Code blocks receive special treatment. While incomplete, they render as an empty <pre><code> shell rather than as a broken paragraph:

Stream:   "```js\nconsole.log"
Renders:  <pre><code class="language-js"></code></pre>   (empty shell)

Stream:   "```js\nconsole.log('hi')\n```"
Renders:  <pre><code class="language-js">console.log('hi')</code></pre>

This prevents the common problem where ```js renders as a paragraph until the closing fence arrives.

Render Batching

The batchMs prop debounces DOM updates during high-frequency token streams:

// Default: 16ms = max 60fps updates
<StreamingMarkdown content={text} batchMs={16} />

// Smoother: 8ms = max 120fps updates
<StreamingMarkdown content={text} batchMs={8} />

// Lighter: 32ms = max 30fps, less CPU
<StreamingMarkdown content={text} batchMs={32} />

// No batching: update on every token (useful for testing)
<StreamingMarkdown content={text} batchMs={0} />

Choose based on your use case. For most AI chat UIs, batchMs={16} (the default) provides a good balance between smoothness and CPU usage.

Styling

The component renders a wrapper <div> that accepts a className prop:

<StreamingMarkdown
  content={text}
  className="prose prose-sm max-w-none dark:prose-invert"
/>

This works well with the Tailwind Typography plugin for styled prose output.

Static Rendering for Complete Messages

For messages that are already complete (historical transcript), use the Markdown component instead. It skips all streaming overhead:

import { Markdown, StreamingMarkdown } from "@deltakit/markdown";

// Active streaming message
<StreamingMarkdown content={streamingText} />

// Historical messages
<Markdown content={completedText} />

See the Markdown API reference for details.

Notes

  • The component parses the full content string on every render. This is fast (the parser handles 300k+ ops/sec on realistic content) but means it's not truly incremental at the parse level — only at the render level.
  • Block IDs are assigned sequentially starting from 0. They reset on each parse call, so the same content always produces the same block structure.
  • batchMs is implemented with setTimeout. If React 18+ concurrent features are available, the component still uses setTimeout for predictable timing behavior.
  • Streaming CSS styles (opacity transitions, image skeleton animations) are injected once via a ref-counted singleton <style> tag in document.head, not per component instance. This keeps style recalculation cost constant regardless of how many StreamingMarkdown instances are mounted.

On this page