logo svelte /markdown v2.2.0

LLM Streaming

@humanspeak/svelte-markdown handles real-time streaming from Large Language Models (LLMs) like ChatGPT, Claude, Gemini, and other AI assistants out of the box. As tokens arrive via Server-Sent Events (SSE) or WebSocket connections, you can write them directly to the component instance with writeChunk() and the rendered markdown updates instantly.

This is also the path for rendering streaming agent output that mixes markdown with rich HTML (designs, dashboards, interactive artifacts). Built-in sanitization runs per token as it is emitted, so a partial <script> or <a href="javascript:..."> arriving mid-stream is sanitized before render. Half-open tags buffer until the parser sees enough to emit a well-formed token, so progressive HTML renders without flicker.

How It Works

LLM APIs stream responses token-by-token. Each token is a small chunk of text — sometimes a word, sometimes a partial word, sometimes punctuation or whitespace. The typical integration pattern is:

  1. The LLM API sends tokens via Server-Sent Events (SSE) or a streaming HTTP response.
  2. Your application accumulates tokens into a growing markdown string, either manually or through writeChunk().
  3. SvelteMarkdown re-lexes only the open block at the end of the source and diffs the result against the previous tokens.
  4. Svelte’s fine-grained reactivity updates only the DOM nodes whose tokens changed.

The component works reactively by default. For best streaming performance, pass streaming={true}: updates are coalesced once per animation frame, only the open block at the end of the source is re-lexed (the tail window), every unchanged token object is reused so its component receives no new props, and root tokens render in offset-bucketed segments so the keyed diff touches only the segment that changed. Per-update cost is proportional to the open block, not to the document. Streamed output is verified against a one-shot parse in the benchmark suite at every sampled frame, and by a seeded fuzz suite that splits random documents at random chunk boundaries.

Note: streaming is automatically disabled when an extension sets async: true (async walkTokens callbacks), since those are incompatible with the synchronous diffing path. A console warning is logged in this case. The built-in extensions, including markedMermaid, tokenize synchronously and keep streaming enabled. To draw Mermaid diagrams while their fence is still open, see Live Diagrams While Streaming.

Basic Usage

Preferred: imperative chunk writes

<script lang="ts">
    import SvelteMarkdown from '@humanspeak/svelte-markdown'
    import type { StreamingChunk } from '@humanspeak/svelte-markdown'

    let markdown:
        | {
              writeChunk: (chunk: StreamingChunk) => void
              resetStream: (nextSource?: string) => void
          }
        | undefined

    async function streamFromAPI() {
        const response = await fetch('/api/chat', { method: 'POST', body: '...' })
        if (!response.body) throw new Error('No response body')
        const reader = response.body.getReader()
        const decoder = new TextDecoder()

        markdown?.resetStream('')

        while (true) {
            const { done, value } = await reader.read()
            if (done) break
            markdown?.writeChunk(decoder.decode(value, { stream: true }))
        }
    }
</script>

<SvelteMarkdown bind:this={markdown} source="" streaming={true} />
<script lang="ts">
    import SvelteMarkdown from '@humanspeak/svelte-markdown'
    import type { StreamingChunk } from '@humanspeak/svelte-markdown'

    let markdown:
        | {
              writeChunk: (chunk: StreamingChunk) => void
              resetStream: (nextSource?: string) => void
          }
        | undefined

    async function streamFromAPI() {
        const response = await fetch('/api/chat', { method: 'POST', body: '...' })
        if (!response.body) throw new Error('No response body')
        const reader = response.body.getReader()
        const decoder = new TextDecoder()

        markdown?.resetStream('')

        while (true) {
            const { done, value } = await reader.read()
            if (done) break
            markdown?.writeChunk(decoder.decode(value, { stream: true }))
        }
    }
</script>

<SvelteMarkdown bind:this={markdown} source="" streaming={true} />

writeChunk() accepts:

  • string for append mode
  • { value, offset } for websocket-style offset patches

The first successful write locks the stream into one mode until resetStream(), a streamId change, or a source prop reset. If an offset patch skips ahead, missing positions are padded with spaces.

With websocket-style offset chunks

markdown?.writeChunk({ value: 'Hel', offset: 0 })
markdown?.writeChunk({ value: 'lo', offset: 3 })
markdown?.writeChunk({ value: ' world', offset: 5 })
markdown?.writeChunk({ value: 'Hel', offset: 0 })
markdown?.writeChunk({ value: 'lo', offset: 3 })
markdown?.writeChunk({ value: ' world', offset: 5 })

Offset chunks use overwrite semantics, not insert semantics. Each patch writes value starting at offset, preserves any trailing content after the overwritten span, and pads skipped gaps with spaces.

This means out-of-order websocket delivery is fine:

markdown?.writeChunk({ value: ' world', offset: 5 })
markdown?.writeChunk({ value: 'Hello', offset: 0 })
markdown?.writeChunk({ value: ' world', offset: 5 })
markdown?.writeChunk({ value: 'Hello', offset: 0 })

There is no delete or truncate behavior in offset mode, and offsets must be non-negative safe integers.

Like append mode, the first successful write locks the stream shape until resetStream(), a streamId change, or a source prop reset.

Resetting Between Messages

The streaming buffer, the incremental parser, and the input-mode lock are per-component-instance state. They outlive any single message. If one instance is reused for a second stream without being reset, the new stream starts on top of the previous message’s buffer.

This is easy to hit in a chat transcript, because Svelte reuses the component instance whenever it is not keyed by message identity:

<!-- ⚠️ Broken: one recycled instance, no reset between messages -->
{#each messages as message}
    <SvelteMarkdown bind:this={markdown} source="" streaming={true} />
{/each}
<!-- ⚠️ Broken: one recycled instance, no reset between messages -->
{#each messages as message}
    <SvelteMarkdown bind:this={markdown} source="" streaming={true} />
{/each}

Holding source="" for the whole conversation means the source prop never changes, so nothing ever triggers the implicit reset. The symptoms differ by mode:

  • Append mode — the next message renders as previous message + new message.
  • Offset mode — writes overwrite in place without truncating, so the previous message’s tail survives past the end of the new one. It does not self-correct until the new message grows longer than the old one.
  • Either mode — the previous stream’s input-mode lock is still in force, so the first chunk of the new stream is dropped with a warning if it uses the other chunk type.

Pass a streamId that changes per message. Whenever its value changes, the component drops the buffer, any pending unflushed chunk, the parser, and the mode lock, then rebaselines on the current source:

<!-- ✅ Correct: streamId identifies the stream -->
{#each messages as message}
    <SvelteMarkdown bind:this={markdown} source="" streaming={true} streamId={message.id} />
{/each}
<!-- ✅ Correct: streamId identifies the stream -->
{#each messages as message}
    <SvelteMarkdown bind:this={markdown} source="" streaming={true} streamId={message.id} />
{/each}

streamId accepts a string or number and is ignored when streaming is false.

There are three equivalent ways to start a clean stream, in rough order of preference:

  1. streamId={message.id} — declarative, and works even when the instance is recycled.
  2. A keyed each — {#each messages as message (message.id)} gives each message its own instance, so there is nothing to reset. Use this when the key identifies the message rather than a slot in a virtual list.
  3. markdown.resetStream() — imperative; call it before the new stream’s first writeChunk().

Note: resetting by changing source is not safe to combine with a same-tick writeChunk(). writeChunk() runs synchronously, while a prop-driven reset runs in an effect, so the write would land on the old buffer and then be wiped. streamId and resetStream() both take effect immediately and have no such ordering hazard.

SDK Examples

Choose between chunked mode (imperative writeChunk() API) and concat mode (reactive source += chunk pattern):

With the Anthropic SDK (Claude)

<script>
    import SvelteMarkdown from '@humanspeak/svelte-markdown'
    import Anthropic from '@anthropic-ai/sdk'

    let markdown

    async function streamResponse(prompt) {
        const client = new Anthropic()

        const stream = client.messages.stream({
            model: 'claude-sonnet-4-20250514',
            max_tokens: 1024,
            messages: [{ role: 'user', content: prompt }]
        })

        markdown?.resetStream('')

        for await (const event of stream) {
            if (
                event.type === 'content_block_delta' &&
                event.delta.type === 'text_delta'
            ) {
                markdown?.writeChunk(event.delta.text)
            }
        }
    }
</script>

<SvelteMarkdown bind:this={markdown} source="" streaming={true} />
<script>
    import SvelteMarkdown from '@humanspeak/svelte-markdown'
    import Anthropic from '@anthropic-ai/sdk'

    let markdown

    async function streamResponse(prompt) {
        const client = new Anthropic()

        const stream = client.messages.stream({
            model: 'claude-sonnet-4-20250514',
            max_tokens: 1024,
            messages: [{ role: 'user', content: prompt }]
        })

        markdown?.resetStream('')

        for await (const event of stream) {
            if (
                event.type === 'content_block_delta' &&
                event.delta.type === 'text_delta'
            ) {
                markdown?.writeChunk(event.delta.text)
            }
        }
    }
</script>

<SvelteMarkdown bind:this={markdown} source="" streaming={true} />

With the OpenAI SDK (ChatGPT)

<script>
    import SvelteMarkdown from '@humanspeak/svelte-markdown'
    import OpenAI from 'openai'

    let markdown

    async function streamResponse(prompt) {
        const client = new OpenAI()

        const stream = await client.chat.completions.create({
            model: 'gpt-4o',
            messages: [{ role: 'user', content: prompt }],
            stream: true
        })

        markdown?.resetStream('')

        for await (const chunk of stream) {
            const delta = chunk.choices[0]?.delta?.content
            if (delta) {
                markdown?.writeChunk(delta)
            }
        }
    }
</script>

<SvelteMarkdown bind:this={markdown} source="" streaming={true} />
<script>
    import SvelteMarkdown from '@humanspeak/svelte-markdown'
    import OpenAI from 'openai'

    let markdown

    async function streamResponse(prompt) {
        const client = new OpenAI()

        const stream = await client.chat.completions.create({
            model: 'gpt-4o',
            messages: [{ role: 'user', content: prompt }],
            stream: true
        })

        markdown?.resetStream('')

        for await (const chunk of stream) {
            const delta = chunk.choices[0]?.delta?.content
            if (delta) {
                markdown?.writeChunk(delta)
            }
        }
    }
</script>

<SvelteMarkdown bind:this={markdown} source="" streaming={true} />

With fetch and Server-Sent Events

<script>
    import SvelteMarkdown from '@humanspeak/svelte-markdown'

    let markdown

    async function streamFromAPI(prompt) {
        const response = await fetch('/api/chat', {
            method: 'POST',
            body: JSON.stringify({ prompt }),
            headers: { 'Content-Type': 'application/json' }
        })

        if (!response.ok) throw new Error(`HTTP ${response.status}`)

        const reader = response.body.getReader()
        const decoder = new TextDecoder()

        markdown?.resetStream('')

        try {
            while (true) {
                const { done, value } = await reader.read()
                if (done) break
                markdown?.writeChunk(decoder.decode(value, { stream: true }))
            }
        } finally {
            reader.releaseLock()
        }
    }
</script>

<SvelteMarkdown bind:this={markdown} source="" streaming={true} />
<script>
    import SvelteMarkdown from '@humanspeak/svelte-markdown'

    let markdown

    async function streamFromAPI(prompt) {
        const response = await fetch('/api/chat', {
            method: 'POST',
            body: JSON.stringify({ prompt }),
            headers: { 'Content-Type': 'application/json' }
        })

        if (!response.ok) throw new Error(`HTTP ${response.status}`)

        const reader = response.body.getReader()
        const decoder = new TextDecoder()

        markdown?.resetStream('')

        try {
            while (true) {
                const { done, value } = await reader.read()
                if (done) break
                markdown?.writeChunk(decoder.decode(value, { stream: true }))
            }
        } finally {
            reader.releaseLock()
        }
    }
</script>

<SvelteMarkdown bind:this={markdown} source="" streaming={true} />

Optional text arrival effects

Core rendering stays unchanged: ordinary text has no segment wrappers, tracking or animation. Installing Motion enables nothing. streamingText opts into source arrival bookkeeping only, on the synchronous streaming path. Choose a renderer or snippet separately. Async extensions disable this bookkeeping along with synchronous streaming.

Install Motion explicitly when using presets or your own Motion snippet (the presets require the compatible 2.0.1-0 release or later):

pnpm add @humanspeak/svelte-markdown @humanspeak/svelte-motion@^2.0.1-0
pnpm add @humanspeak/svelte-markdown @humanspeak/svelte-motion@^2.0.1-0

Pick a packaged preset

<script lang="ts">
    import SvelteMarkdown from '@humanspeak/svelte-markdown'
    import { FadeWords } from '@humanspeak/svelte-markdown/streaming/motion'

    let source = $state('')
    let streamId = $state(0)
    function receive(chunk: string) { source += chunk }
    function restart() { source = ''; streamId++ }
</script>

<button onclick={restart}>Restart</button>
<button onclick={() => receive('New words ')}>Append</button>
<SvelteMarkdown {source} {streamId} streaming streamingText renderers={{ rawtext: FadeWords }} />
<script lang="ts">
    import SvelteMarkdown from '@humanspeak/svelte-markdown'
    import { FadeWords } from '@humanspeak/svelte-markdown/streaming/motion'

    let source = $state('')
    let streamId = $state(0)
    function receive(chunk: string) { source += chunk }
    function restart() { source = ''; streamId++ }
</script>

<button onclick={restart}>Restart</button>
<button onclick={() => receive('New words ')}>Append</button>
<SvelteMarkdown {source} {streamId} streaming streamingText renderers={{ rawtext: FadeWords }} />

Swap the import and rawtext registration for RiseWords or FadeCharacters:

ExportSegmentationEntranceLayout
FadeWordsLocale-aware word spansLinear opacity fade with an ink wipeInline-block ink wrappers
RiseWordsLocale-aware word spansLinear opacity fade, 8px eased rise and an ink wipeInline-block spans
FadeCharactersUnicode grapheme clustersOpacityOrdinary inline spans

Whitespace stays literal outside Motion spans. Rise’s inline-block words can affect line wrapping; grapheme rendering creates more DOM nodes than word rendering. Code blocks and inline code retain their code renderers and are excluded from rawtext effects.

All three presets accept text, streamingText, enabled (true), ink, animateRevisions (false), animateInitialContent (false), initial, animate, transition, variants, custom, locale, segmenter and a segment snippet. Motion prop types come from MotionSpan; a consumer segment snippet replaces all preset markup. With enabled={false}, presets render escaped plain text without wrappers. All defaults use a leaf-local batch stagger of 0.02s capped at 0.16s. FadeWords fades over 0.65s with linear easing. RiseWords uses the same fade plus an 8px rise over 0.4s with easeOut. FadeCharacters fades over 0.18s with Motion’s default easing.

FadeWords and RiseWords also apply an ink wipe by default: a feathered left-to-right mask reveal over 0.8s on arriving words, sharing the batch stagger. Pass ink={false} to disable it or ink={{ duration }} to retime it; FadeCharacters accepts ink but leaves it off by default. The wipe runs only on entering segments, never on baseline content, and is skipped entirely with enabled={false} or a custom segment snippet. Passing transition replaces the entire default transition, including easing and stagger; initial and animate likewise replace the preset targets.

For example, save a configured renderer as MyRise.svelte and register it with renderers={{ rawtext: MyRise }}:

<script lang="ts">
    import { RiseWords } from '@humanspeak/svelte-markdown/streaming/motion'
    import type { StreamingMotionProps } from '@humanspeak/svelte-markdown/streaming/motion'
    const { text = '', streamingText, enabled = true }: StreamingMotionProps = $props()
</script>

<RiseWords {text} {streamingText} {enabled}
    initial={{ opacity: 0, y: 4 }} animate={{ opacity: 1, y: 0 }}
    transition={{ duration: 0.25 }} />
<script lang="ts">
    import { RiseWords } from '@humanspeak/svelte-markdown/streaming/motion'
    import type { StreamingMotionProps } from '@humanspeak/svelte-markdown/streaming/motion'
    const { text = '', streamingText, enabled = true }: StreamingMotionProps = $props()
</script>

<RiseWords {text} {streamingText} {enabled}
    initial={{ opacity: 0, y: 4 }} animate={{ opacity: 1, y: 0 }}
    transition={{ duration: 0.25 }} />

Baseline text always uses initial={false}, even when you supply initial, unless you explicitly set animateInitialContent. Revisions use initial={false} unless animateRevisions is enabled. SSR seeds are baseline, so existing text is visible on the server and during hydration. An unfinished segment retains its keyed node and creation eligibility as its text grows.

Fade extension output such as math

Extension tokens such as KaTeX math are opaque: their contents never become word segments. Wrap their renderer in Fade to fade the whole token in when it arrives mid-stream.

<script lang="ts">
    import SvelteMarkdown from '@humanspeak/svelte-markdown'
    import { KatexRenderer, markedKatex, type KatexSnippetProps } from '@humanspeak/svelte-markdown/extensions/katex'
    import { Fade, RiseWords } from '@humanspeak/svelte-markdown/streaming/motion'
    let source = $state('')
    const extensions = [markedKatex()]
</script>

{#snippet math({ text, displayMode, streamingText }: KatexSnippetProps)}
    <Fade {streamingText} block={displayMode}><KatexRenderer {text} {displayMode} /></Fade>
{/snippet}

<SvelteMarkdown {source} streaming streamingText {extensions} inlineKatex={math} blockKatex={math}>
    {#snippet rawtext({ text, streamingText })}<RiseWords {text} {streamingText} />{/snippet}
</SvelteMarkdown>
<script lang="ts">
    import SvelteMarkdown from '@humanspeak/svelte-markdown'
    import { KatexRenderer, markedKatex, type KatexSnippetProps } from '@humanspeak/svelte-markdown/extensions/katex'
    import { Fade, RiseWords } from '@humanspeak/svelte-markdown/streaming/motion'
    let source = $state('')
    const extensions = [markedKatex()]
</script>

{#snippet math({ text, displayMode, streamingText }: KatexSnippetProps)}
    <Fade {streamingText} block={displayMode}><KatexRenderer {text} {displayMode} /></Fade>
{/snippet}

<SvelteMarkdown {source} streaming streamingText {extensions} inlineKatex={math} blockKatex={math}>
    {#snippet rawtext({ text, streamingText })}<RiseWords {text} {streamingText} />{/snippet}
</SvelteMarkdown>

A token arrives in the batch where its whole source span first parses as that token, typically when the closing delimiter arrives; characters already shown as plain text before that do not count. Arrival is keyed by source identity, so remounts of an already revealed token (a paragraph becoming a setext heading or list, a reparse) and baseline content render without an entrance. Fade accepts streamingText, enabled (true), block (false; a div instead of a span), animateRevisions (false), animateInitialContent (false), initial, animate and transition; the default is a 0.65s linear opacity fade. If an extension shapes the parse in a way the tracer cannot map, its tokens get no arrival and render without an entrance.

Own every segment with StreamingText

StreamingText is exported from the core and has no Motion dependency. Without a segment snippet it renders plain escaped text without wrappers. With a snippet it segments and keys your markup. The live example exposes the actual MotionStreamingText.svelte source, plus duration, capped stagger, word/grapheme, preset selection and restart controls.

<script lang="ts">
    import SvelteMarkdown, { StreamingText } from '@humanspeak/svelte-markdown'
    import { MotionSpan } from '@humanspeak/svelte-motion'
    import { onMount } from 'svelte'

    let markdown = $state<SvelteMarkdown>()
    let streamId = $state(0)
    let enabled = $state(false)
    onMount(() => {
        const query = window.matchMedia('(prefers-reduced-motion: reduce)')
        const update = () => { enabled = !query.matches }
        update()
        query.addEventListener('change', update)
        return () => query.removeEventListener('change', update)
    })
    function restart() { streamId++; markdown?.resetStream('') }
</script>

<button onclick={restart}>Restart</button>
<button onclick={() => markdown?.writeChunk('New words ')}>Append</button>
<SvelteMarkdown bind:this={markdown} source="" {streamId} streaming streamingText>
    {#snippet rawtext({ text, streamingText })}
        <StreamingText {text} metadata={streamingText} granularity="word">
            {#snippet segment(part)}
                {#if !enabled || part.isWhitespace}
                    {part.text}
                {:else}
                    <MotionSpan initial={part.isNew ? { opacity: 0 } : false}
                        animate={{ opacity: 1 }}
                        transition={{ duration: 0.18, delay: Math.min(part.batchIndex * 0.02, 0.16) }}
                        >{part.text}</MotionSpan>
                {/if}
            {/snippet}
        </StreamingText>
    {/snippet}
</SvelteMarkdown>
<script lang="ts">
    import SvelteMarkdown, { StreamingText } from '@humanspeak/svelte-markdown'
    import { MotionSpan } from '@humanspeak/svelte-motion'
    import { onMount } from 'svelte'

    let markdown = $state<SvelteMarkdown>()
    let streamId = $state(0)
    let enabled = $state(false)
    onMount(() => {
        const query = window.matchMedia('(prefers-reduced-motion: reduce)')
        const update = () => { enabled = !query.matches }
        update()
        query.addEventListener('change', update)
        return () => query.removeEventListener('change', update)
    })
    function restart() { streamId++; markdown?.resetStream('') }
</script>

<button onclick={restart}>Restart</button>
<button onclick={() => markdown?.writeChunk('New words ')}>Append</button>
<SvelteMarkdown bind:this={markdown} source="" {streamId} streaming streamingText>
    {#snippet rawtext({ text, streamingText })}
        <StreamingText {text} metadata={streamingText} granularity="word">
            {#snippet segment(part)}
                {#if !enabled || part.isWhitespace}
                    {part.text}
                {:else}
                    <MotionSpan initial={part.isNew ? { opacity: 0 } : false}
                        animate={{ opacity: 1 }}
                        transition={{ duration: 0.18, delay: Math.min(part.batchIndex * 0.02, 0.16) }}
                        >{part.text}</MotionSpan>
                {/if}
            {/snippet}
        </StreamingText>
    {/snippet}
</SvelteMarkdown>

Reduced motion belongs to the consumer. Initialize animation disabled for SSR, then subscribe to matchMedia in onMount and remove the listener on teardown, as above. Pass the same reactive enabled to a configured preset wrapper. No AnimatePresence is needed for append entrances. Reveal pacing controls when text appears; arrival effects control its presentation once it appears. They are separate choices.

API and identity

StreamingText props are text (empty by default), metadata, granularity ('word' by default or 'grapheme'), locale, segmenter, and segment: Snippet<[StreamingTextSegment]>. SvelteMarkdown delivers reserved streamingText?: StreamingTextMetadata to leaf components and rawtext snippets; passthrough props cannot override it. Forward it to metadata on the helper rather than to a DOM element.

The exported types contain only readonly fields:

interface StreamingTextSpan { readonly text: string; readonly start: number; readonly end: number }
type StreamingTextGranularity = 'word' | 'grapheme'
type StreamingTextChange = 'baseline' | 'append' | 'revision'
type StreamingTextSegmenter = (text: string) => readonly StreamingTextSpan[]
interface StreamingTextSegment extends StreamingTextSpan {
    readonly id: string
    readonly index: number
    readonly isNew: boolean
    readonly batchId: number
    readonly batchIndex: number
    readonly isWhitespace: boolean
    readonly change: StreamingTextChange
}
interface StreamingTextRange {
    readonly start: number; readonly end: number; readonly originId: string
    readonly change: StreamingTextChange; readonly batchId: number
    readonly revealedBeforeBatch: boolean
}
interface StreamingTextArrival {
    readonly change: StreamingTextChange; readonly batchId: number
    readonly revealedBeforeBatch: boolean
}
interface StreamingTextMetadata {
    readonly epoch: number; readonly leafId: string; readonly renderBatchId: number
    readonly provenance: 'exact' | 'unknown'
    readonly ranges: readonly StreamingTextRange[]
    readonly arrival?: StreamingTextArrival // extension tokens only
}
interface StreamingTextSpan { readonly text: string; readonly start: number; readonly end: number }
type StreamingTextGranularity = 'word' | 'grapheme'
type StreamingTextChange = 'baseline' | 'append' | 'revision'
type StreamingTextSegmenter = (text: string) => readonly StreamingTextSpan[]
interface StreamingTextSegment extends StreamingTextSpan {
    readonly id: string
    readonly index: number
    readonly isNew: boolean
    readonly batchId: number
    readonly batchIndex: number
    readonly isWhitespace: boolean
    readonly change: StreamingTextChange
}
interface StreamingTextRange {
    readonly start: number; readonly end: number; readonly originId: string
    readonly change: StreamingTextChange; readonly batchId: number
    readonly revealedBeforeBatch: boolean
}
interface StreamingTextArrival {
    readonly change: StreamingTextChange; readonly batchId: number
    readonly revealedBeforeBatch: boolean
}
interface StreamingTextMetadata {
    readonly epoch: number; readonly leafId: string; readonly renderBatchId: number
    readonly provenance: 'exact' | 'unknown'
    readonly ranges: readonly StreamingTextRange[]
    readonly arrival?: StreamingTextArrival // extension tokens only
}

Offsets are leaf-local UTF-16 positions. index is the current segment position; batchIndex is leaf-local within its arrival batch, not global document stagger. isNew, change, batchId and batchIndex retain the segment’s creation values during unrelated updates and unfinished-word completion; isNew describes entrance eligibility, not a timer. Metadata and segments are frozen. Never modify token objects to control this API.

First content and reset seeds are baseline. Source replacements, resetStream, streamId, mode changes and disabling/re-enabling tracking discard the previous arrival epoch. Existing visible characters moved under completed markdown wrappers remain revealed, although their DOM may remount. New keys alone do not imply arrival. Offset overwrites classify changed positions as revisions while unchanged positions keep identity; padded gaps are baseline and later fills are revisions. Identical repeated words have distinct source identities. Tokens produced by custom extension tokenizers (for example KaTeX math) expose provenance: 'unknown' with no ranges, while surrounding markdown text keeps exact provenance and its entrances; their whole-token arrival drives Fade. A walkTokens hook or custom tokenizer makes the whole parse unknown and suppresses automatic entrances; output is preserved either way.

Word and grapheme segmentation require Intl.Segmenter (modern browsers and Node) or a custom segmenter. Missing support throws clearly. A custom segmenter returns ordered UTF-16 spans covering the exact input once; gaps, overlaps, invalid offsets or changed text throw instead of dropping content. Use a custom segmenter for your application’s boundaries. Changing locale, granularity or segmenter rebaselines existing segments. Changed leaves are resegmented with full context to handle punctuation, combining marks and split surrogate pairs; completed unchanged leaves remain cached. Parsing a long open paragraph/list and full-parser fallbacks can still cost proportional to that block/document. Tracking and segment DOM cost are opt-in.

Performance Characteristics

Measured on 2026-09-28 with the repository’s paired benchmark (pnpm perf:stream-compare): headless production Chromium, cumulative-source updates of 32 characters per animation frame (about 480 tokens per second, faster than real model output), ~24 KB corpora, and a metric of main-thread work per frame (synchronous update plus deferred frame work plus a forced layout). Median of five iterations, two suites; parity against a fresh parse was zero mismatches in every run.

Scenario (~24 KB streamed at 32 chars/frame)Avg work per framep95 per frameFrames over 16.7 ms
Mixed prose (headings, lists, code, tables)~2.4 ms4.3–4.5 ms0 of 765
Same corpus, four updates per frame~2.9 ms5.4–5.5 ms0 of 192
One open bullet list (~200 items)~4.7 ms7.3–7.9 ms1 of 754
One open table (~150 rows)~5.1 ms8.2–11.2 ms1–2 of 314
One open code fence~2.1 ms3.3–3.4 ms0 of 753
Citation-heavy prose with 40 trailing [n]: url definitions~2.1 ms3.9–4.3 ms0 of 753

Absolute milliseconds are machine-specific (Intel i7-6700K in this capture); the shape is what matters. Every scenario stays inside the 60 fps budget on nearly every frame, and the long open list and table — the shapes that used to hurt most — now cost a few milliseconds per frame.

How Render Time Scales

The renderer’s own work does not grow with document length. Streaming the same 2 KB tail after a closed prefix of 24 KB, 96 KB and 384 KB, the parser plus render work per frame was 1.4 ms at 24 KB and 1.9 ms at 384 KB. What does grow is the browser’s style and layout pass over the whole document, which the benchmark forces on every frame (total average work 1.8 ms, 3.5 ms and 13.6 ms at those sizes). A real page pays layout only for the region that changed, and long conversations should split completed messages into their own components anyway (see below).

For a comparison with another streaming renderer under the same method, see Svelte Streamdown vs Svelte Markdown.

Best Practices

1. Prefer Word-Level Chunking

If you control the chunking strategy (e.g., in a custom SSE endpoint), emit tokens at word boundaries rather than individual characters. This reduces the total number of re-renders while producing the same visual result:

// Server-side: buffer tokens and emit at word boundaries
let buffer = ''
for await (const token of llmStream) {
    buffer += token
    if (buffer.endsWith(' ') || buffer.endsWith('\n')) {
        controller.enqueue(encoder.encode(buffer))
        buffer = ''
    }
}
if (buffer) controller.enqueue(encoder.encode(buffer))
// Server-side: buffer tokens and emit at word boundaries
let buffer = ''
for await (const token of llmStream) {
    buffer += token
    if (buffer.endsWith(' ') || buffer.endsWith('\n')) {
        controller.enqueue(encoder.encode(buffer))
        buffer = ''
    }
}
if (buffer) controller.enqueue(encoder.encode(buffer))

2. Use Token Caching for Chat History

When displaying a conversation with multiple messages, previously completed messages are re-rendered on every update. Enable token caching so that completed messages skip re-parsing:

<script>
    import SvelteMarkdown from '@humanspeak/svelte-markdown'

    let messages = $state([])
</script>

{#each messages as message}
    <!-- Completed messages hit the token cache automatically -->
    <SvelteMarkdown source={message.content} />
{/each}
<script>
    import SvelteMarkdown from '@humanspeak/svelte-markdown'

    let messages = $state([])
</script>

{#each messages as message}
    <!-- Completed messages hit the token cache automatically -->
    <SvelteMarkdown source={message.content} />
{/each}

3. Debounce for Extremely Fast Streams

With streaming={true} this is already done for you: incoming updates are coalesced and applied once per animation frame, which is why four updates per frame cost about the same as one in the table above. If you render without streaming and your LLM stream is unusually fast (100+ tokens/second), you can batch updates yourself using requestAnimationFrame:

let pending = ''
let rafScheduled = false

function onToken(token) {
    pending += token
    if (!rafScheduled) {
        rafScheduled = true
        requestAnimationFrame(() => {
            source += pending
            pending = ''
            rafScheduled = false
        })
    }
}
let pending = ''
let rafScheduled = false

function onToken(token) {
    pending += token
    if (!rafScheduled) {
        rafScheduled = true
        requestAnimationFrame(() => {
            source += pending
            pending = ''
            rafScheduled = false
        })
    }
}

This coalesces multiple tokens into a single render per frame, reducing total renders from ~100/sec to ~60/sec while maintaining smooth visual output.

Estimating Streaming Costs

When building production LLM streaming UIs, understanding token costs is as important as render performance. Each streamed token has a price that varies by model, provider, and whether it’s an input or output token. ModelPricing.ai provides a pricing estimation API that covers all major LLM providers — useful for displaying real-time cost tracking alongside your streamed responses, setting usage budgets, or building cost-aware model selection into your application.

Try It Live

Experiment with different streaming speeds, jitter, chunk modes, and websocket-style offset patch simulation in the interactive LLM streaming demo.

Related