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:
- Performance Overhead: Rebuilding the entire Document Object Model (DOM) from scratch is computationally expensive, leading to UI jank, especially for longer messages.
- 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.
- 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:
BlazorObjectRenderer<'TObject>
: An abstract base class that extends Markdig'sMarkdownObjectRenderer
to work with our customBlazorMarkdownRenderer
.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 MarkdigHtmlRenderer
's output into aStringWriter
and then passes that raw HTML to Blazor usinghtml.raw
. This ensures that even for simple elements, Blazor is aware of the content and can diff it.BlazorMarkdownRenderer
: This is the central custom MarkdigRendererBase
.- It holds a
RenderTreeBuilder
andIComponent
context provided by the Blazor component that initiated the rendering. - It overrides the
Render
method to delegate to specificObjectRenderers
(our custom ones). - Crucially, its
SetupRenders
method registers a mix of custom renderers (CodeBlockRenderer
,TaskListRenderer
,ListRenderer
) and wrapped standardHtmlObjectRenderer
instances (viaBlazorHtmlObjectRenderer
).
- It holds a
// 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
: Handlescode
inline elements, applying highlighting (e.g., viawindow.highlightCode()
).TaskListRenderer
: Renders Markdig's task lists as HTMLinput 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:
- True Blazor Diffing: Only the changed Markdown blocks cause re-renders, leading to much smoother and faster UI updates, especially for streaming content.
- 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.
- 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).
- 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.