slaveOftime

Optimizing Markdown Rendering in Blazor for LLM Chat Applications

2025-08-01

Enhancing Markdown Rendering in Blazor: A Deep Dive into Performance and UX

In the rapidly evolving landscape of Large Language Model (LLM) chat applications, a seamless and responsive user interface is paramount. A common challenge arises when rendering the LLM's responses, which often come in Markdown format. Many implementations, especially those focused on streaming content, resort to continuously re-rendering the entire chat bubble's content from scratch. This approach, while simple to implement, introduces significant performance bottlenecks and degrades the user experience.

The Problem with Full Re-Renders

Imagine a chat application where new tokens arrive constantly. If the entire Markdown content of a message is re-parsed and re-rendered with every new token, several issues emerge:

  1. Performance Overhead: Rebuilding the entire Document Object Model (DOM) from scratch is computationally expensive, leading to UI jank, especially for longer messages.
  2. Loss of User Interaction: When the DOM is completely replaced, user selections (e.g., trying to copy a piece of text), input focus, and even scroll positions can be lost or reset, making the application frustrating to use.
  3. Inefficient Diffing: Blazor, at its core, excels at efficient UI updates through its RenderTreeBuilder and diffing algorithm. However, if you're constantly providing entirely new content, Blazor has no "previous state" to compare against, forcing a full rebuild.

This article explores a solution implemented in F# (conceptually identical to C#) that leverages the Markdig Markdown parser and Blazor's native diffing capabilities to create a more performant and user-friendly Markdown renderer, as demonstrated in the Brainloop LLM application.

Leveraging Markdig and Blazor's Diffing Advantage

The key insight is to integrate Markdig's parsing with Blazor's rendering pipeline. Instead of Markdig directly outputting a monolithic HTML string, we want it to "tell" Blazor what components and elements to render, allowing Blazor to perform its intelligent diffing. This means only the changed parts of the Markdown output will trigger DOM updates, preserving the state of unchanged blocks.

The core of this solution involves creating a custom MarkdownRenderer that can interact with Blazor's RenderTreeBuilder.

The Custom Renderer Architecture

Markdig provides an extensible architecture where you can define custom MarkdownObjectRenderer implementations for specific Markdown syntax elements (like code blocks, lists, paragraphs, etc.). Our approach involves:

  1. BlazorObjectRenderer<'TObject>: An abstract base class that extends Markdig's MarkdownObjectRenderer to work with our custom BlazorMarkdownRenderer.
  2. BlazorHtmlObjectRenderer<'T>: A wrapper renderer. Many standard Markdown elements (headings, paragraphs, links) don't require complex Blazor components; their HTML output is sufficient. This renderer captures the standard Markdig HtmlRenderer's output into a StringWriter and then passes that raw HTML to Blazor using html.raw. This ensures that even for simple elements, Blazor is aware of the content and can diff it.
  3. BlazorMarkdownRenderer: This is the central custom Markdig RendererBase.
    • It holds a RenderTreeBuilder and IComponent context provided by the Blazor component that initiated the rendering.
    • It overrides the Render method to delegate to specific ObjectRenderers (our custom ones).
    • Crucially, its SetupRenders method registers a mix of custom renderers (CodeBlockRenderer, TaskListRenderer, ListRenderer) and wrapped standard HtmlObjectRenderer instances (via BlazorHtmlObjectRenderer).
// Core BlazorObjectRenderer and BlazorHtmlObjectRenderer
[<AbstractClass>]
type BlazorObjectRenderer<'TObject when 'TObject :> MarkdownObject>() =
    inherit MarkdownObjectRenderer<BlazorMarkdownRenderer, 'TObject>()

type BlazorHtmlObjectRenderer<'T when 'T :> MarkdownObject>(htmlObjectRenderer: HtmlObjectRenderer<'T>) =
    inherit BlazorObjectRenderer<'T>()

    override _.Write(renderer: BlazorMarkdownRenderer, obj: 'T) =
        let htmlRenderer = renderer.HtmlRenderer
        htmlObjectRenderer.Write(htmlRenderer, obj :> MarkdownObject)
        htmlRenderer.Writer.Flush()

        renderer.Render(
            fragment {
                match htmlRenderer.Writer.ToString() with
                | null -> ()
                | htmlContent ->
                    html.raw htmlContent // Render raw HTML content
                    if htmlContent.Contains("class=\"math\"") then script { "MathJax.typeset()" } // Example: MathJax typesetting
            }
        )

// BlazorMarkdownRenderer - The heart of the custom rendering
type BlazorMarkdownRenderer() as this =
    inherit RendererBase()

    let mutable builder: RenderTreeBuilder | null = null
    let mutable builderContext: IComponent | null = null
    let mutable sequence = 0

    let htmlRenderer = HtmlRenderer(new StringWriter()) // Used by BlazorHtmlObjectRenderer wrappers

    do this.SetupRenders()

    override _.Render(markdownObject: MarkdownObject) = base.Write(markdownObject)

    // Setup method called by the Blazor component to provide RenderTreeBuilder
    member _.Setup(builder', context') =
        builder <- builder'
        builderContext <- context'
        sequence <- 0

    // Registers all custom and wrapped Markdig renderers
    member _.SetupRenders() =
        base.ObjectRenderers.Add(CodeBlockRenderer())
        base.ObjectRenderers.Add(CodeInlineRenderer())
        base.ObjectRenderers.Add(TaskListRenderer())
        base.ObjectRenderers.Add(ListRenderer())

        // Setup pipeline for standard Markdig renderers (for our BlazorHtmlObjectRenderer)
        MarkdownView.MarkdownPipeline.Setup(this)
        MarkdownView.MarkdownPipeline.Setup(htmlRenderer)

        // Add wrappers for unhandled objects to use string writer for raw HTML
        this.ObjectRenderers.Add(BlazorHtmlObjectRenderer(Markdig.Renderers.Html.HeadingRenderer()))
        this.ObjectRenderers.Add(BlazorHtmlObjectRenderer(Markdig.Renderers.Html.HtmlBlockRenderer()))
        // ... many more BlazorHtmlObjectRenderer for standard HTML elements
        this.ObjectRenderers.Add(BlazorHtmlObjectRenderer(Markdig.Extensions.Mathematics.HtmlMathBlockRenderer()))
        this.ObjectRenderers.Add(BlazorHtmlObjectRenderer(Markdig.Extensions.Tables.HtmlTableRenderer()))

    // Method to allow renderers to "render" a Blazor fragment
    member _.Render(node: NodeRenderFragment) =
        match builder with
        | null -> ()
        | builder -> sequence <- node.Invoke(builderContext, builder, sequence)

// MarkdownView: The entry point Blazor component
type MarkdownView =
    static let markdownPipeline = lazy (MarkdownPipelineBuilder().UseAdvancedExtensions().Build())
    static let blazorMarkdownRendererPool = lazy (Fun.Blazor.Utils.Internal.objectPoolProvider.Create<BlazorMarkdownRenderer>())

    static member MarkdownPipeline: MarkdownPipeline = markdownPipeline.Value

    static member Create(md: string) = div {
        class' "markdown-body"
        NodeRenderFragment(fun comp builder index ->
            builder.OpenRegion(index) // Start a Blazor rendering region

            let renderer = blazorMarkdownRendererPool.Value.Get() // Get a renderer from a pool for performance
            try
                renderer.Setup(builder, comp) // Provide Blazor's builder and component context
                let document = Markdig.Markdown.Parse(md, markdownPipeline.Value)
                renderer.Render(document) |> ignore // Tell Markdig to render the document using our custom renderer
            finally
                blazorMarkdownRendererPool.Value.Return(renderer) // Return renderer to pool

            builder.CloseRegion() // Close the region
            index + 1
        )
    }

Specialized Block Renderers for Richer UX

The true power comes from custom renderers for specific Markdown blocks, where we want more than just raw HTML.

CodeBlockRenderer

This renderer is particularly sophisticated, handling:

  • Syntax Highlighting: Integrates Prism.js for beautiful code highlighting.
  • Diagrams as Code: Supports Mermaid.js for rendering flowcharts, sequence diagrams, etc., directly from Markdown code blocks. It also ensures correct initialization, including dark mode detection and deterministic IDs for consistent rendering.
  • Copy-to-Clipboard: A convenient button to copy the code block's content.
  • Interactive Preview: For HTML code blocks, it can render the HTML directly in a dialog. For other code, it provides a scrollable dialog preview, useful for very long code snippets.

The logic within CodeBlockRenderer is quite involved. This ensures that code blocks are treated as interactive Blazor components, allowing for rich functionality.

type CodeBlockRenderer() =
    inherit BlazorObjectRenderer<CodeBlock>()

    override _.Write(renderer: BlazorMarkdownRenderer, obj: CodeBlock) =
        let contentLength = // Calculate content length for unique ID
            match box obj.Lines.Lines with
            | null -> 0
            | _ -> obj.Lines.Lines |> Seq.sumBy _.Slice.Length
        let codeId = $"code-{obj.Line}-{contentLength}" // Unique ID for the code block

        renderer.Render(
            html.inject ( // Blazor component injection
                codeId,
                fun (shareStore: IShareStore, dialogService: IDialogService, JS: IJSRuntime) ->
                    let copyBtn = MudIconButton'' { // MudBlazor copy button
                        key "copy"
                        Size Size.Small
                        Variant Variant.Filled
                        Icon Icons.Material.Outlined.ContentCopy
                        OnClick(fun _ -> task { do! JS.CopyInnerText(codeId) })
                    }
                    let previewBtn (showDialog: CodeBlock -> unit) = MudIconButton'' { // MudBlazor preview button
                        key "preview"
                        Size Size.Small
                        Variant Variant.Filled
                        Icon Icons.Material.Outlined.RemoveRedEye
                        OnClick(fun _ -> showDialog obj)
                    }
                    let mainContent (codeId: string) = region { // Main content rendering logic
                        match obj with
                        | :? FencedCodeBlock as fc when "mermaid".Equals(fc.Info, StringComparison.OrdinalIgnoreCase) ->
                            pre { // Mermaid block
                                id codeId
                                BlazorMarkdownRenderer.MakeAttrs(obj)
                                class' "mermaid"
                                BlazorMarkdownRenderer.MakeNode(obj)
                            }
                            adaptiview (key = struct (obj, "script")) { // Adaptive view for dark mode
                                let! isDarkMode = shareStore.IsDarkMode
                                script { // Mermaid initialization script
                                    $$"""
                                    mermaid.init(
                                        {
                                            securityLevel: 'loose',
                                            theme: '{{if isDarkMode then "dark" else "neutral"}}',
                                            deterministicIds: true,
                                            deterministicIDSeed: '{{CodeBlockRenderer.GetNextMermaidIdSeed()}}',
                                        },
                                        document.getElementById('{{codeId}}')
                                    );
                                    """
                                }
                            }
                        | _ ->
                            pre { // Standard code block
                                code {
                                    id codeId
                                    BlazorMarkdownRenderer.MakeAttrs(obj)
                                    BlazorMarkdownRenderer.MakeNode(obj)
                                }
                            }
                            script { // Prism.js highlighting script
                                key (struct (codeId, "script"))
                                $"Prism.highlightElement(document.getElementById('{codeId}'))"
                            }
                    }
                    div { // Wrapper div for positioning buttons
                        style { positionRelative; overflowHidden }
                        mainContent codeId
                        div { // Buttons container
                            style { positionAbsolute; top 12; right 12; displayFlex; alignItemsCenter; gap 8; zIndex 1 }
                            previewBtn (fun obj ->
                                match obj with
                                | :? FencedCodeBlock as fc when "html".Equals(fc.Info, StringComparison.OrdinalIgnoreCase) ->
                                    dialogService.PreviewHtml(BlazorMarkdownRenderer.MakeString(obj))
                                | _ ->
                                    dialogService.Show( // Generic dialog for code preview
                                        DialogOptions(MaxWidth = MaxWidth.ExtraLarge, FullWidth = true),
                                        fun ctx -> MudDialog'' {
                                            Header "Preview" ctx.Close
                                            DialogContent(
                                                div {
                                                    style { overflowHidden; height "calc(100vh - 200px)" }
                                                    styleElt { // Style for scrollable code in dialog
                                                        ruleset """pre[class*="language-"]""" {
                                                            overflowYAuto
                                                            height "100%"
                                                            maxHeight "100% !important"
                                                        }
                                                    }
                                                    mainContent (codeId + "-preview") // Render content again for preview
                                                }
                                            )
                                        }
                                    )
                            )
                            copyBtn
                        }
                    }
            )
        )
Other Specialized Renderers
  • CodeInlineRenderer: Handles code inline elements, applying highlighting (e.g., via window.highlightCode()).
  • TaskListRenderer: Renders Markdig's task lists as HTML input type="checkbox" elements, allowing Blazor to manage their checked state.
  • ListRenderer: Provides more granular control over ordered (<ol>) and unordered (<ul>) lists and their list items (<li>), ensuring Blazor's diffing works effectively for these common structures.
type CodeInlineRenderer() =
    inherit BlazorObjectRenderer<CodeInline>()

    override _.Write(renderer: BlazorMarkdownRenderer, obj: CodeInline) =
        let codeId = $"code-{obj.Line}-{obj.ContentSpan.Length}"
        renderer.Render(
            span {
                style { positionRelative }
                code {
                    id codeId
                    BlazorMarkdownRenderer.MakeAttrs(obj)
                    NodeRenderFragment(fun _ builder sequence ->
                        builder.AddContent(sequence, obj.ContentSpan.ToString())
                        sequence + 1
                    )
                }
                script {
                    key (struct (codeId, "script"))
                    "window.highlightCode()" // Highlight inline code
                }
            }
        )

type TaskListRenderer() =
    inherit BlazorObjectRenderer<TaskList>()

    override _.Write(renderer: BlazorMarkdownRenderer, obj: TaskList) =
        renderer.Render(
            input {
                BlazorMarkdownRenderer.MakeAttrs(obj)
                type' InputTypes.checkbox
                disabled true
                checked' obj.Checked // Blazor manages checked state
            }
        )

type ListRenderer() =
    inherit BlazorObjectRenderer<ListBlock>()

    override _.Write(renderer: BlazorMarkdownRenderer, obj: ListBlock) =
        renderer.Render(
            region {
                let items = fragment {
                    for item in obj do
                        li {
                            BlazorMarkdownRenderer.MakeAttrs(item)
                            renderer.WriteChildren(item :?> ListItemBlock) // Recursively render list item children
                        }
                }
                if obj.IsOrdered then
                    ol { // Ordered list
                        domAttr { if obj.BulletType <> '1' then type' (obj.BulletType.ToString()) }
                        domAttr {
                            match obj.OrderedStart with
                            | null
                            | "1" -> ()
                            | x -> "start" => x
                        }
                        BlazorMarkdownRenderer.MakeAttrs(obj)
                        items
                    }
                else
                    ul { // Unordered list
                        BlazorMarkdownRenderer.MakeAttrs(obj)
                        items
                    }
            }
        )

Benefits of this Approach

By implementing this custom Markdig renderer in Blazor, you gain significant advantages:

  1. True Blazor Diffing: Only the changed Markdown blocks cause re-renders, leading to much smoother and faster UI updates, especially for streaming content.
  2. Enhanced User Experience: Users can select text, interact with elements (like checkboxes in task lists), and maintain focus without the DOM being torn down and rebuilt.
  3. Rich Interactivity: Custom renderers allow injecting Blazor components and logic directly into the Markdown output, enabling features like copy buttons, interactive previews, and dynamic diagram rendering (Mermaid.js).
  4. Optimized Performance: Reduced DOM manipulations translate directly to lower CPU usage and a more responsive application, crucial for real-time LLM chat scenarios.

Below is a demo video

Conclusion

Transforming raw Markdown into a dynamic, interactive, and performant UI in Blazor requires a thoughtful integration strategy. By extending Markdig with custom BlazorObjectRenderer implementations, we can harness Blazor's powerful diffing capabilities, providing a superior user experience and significant performance gains. This approach moves beyond simple HTML string rendering, turning Markdown content into first-class Blazor components, and is highly recommended for any Blazor application dealing with rich text content, particularly in fast-paced scenarios like LLM chat interfaces.

Also you do not need to handle all types block, just some block you want to improve. So you can improve it little by little.

Do you like this post?