Skip to main content
MarkdownView is designed for high-performance markdown rendering in native apps. It uses native C parsers (swift-cmark and tree-sitter) instead of JavaScript-based solutions, lazy initialization, and incremental parsing to achieve excellent performance.

Key optimizations

Native parsers

Swift-cmark and tree-sitter run directly in native code, no JavaScript runtime overhead.

Lazy loading

Language parsers initialize only when needed, keeping startup fast.

Incremental parsing

Reuse unchanged blocks when appending new content.

View pooling

Reusable CodeView and TableView instances reduce allocations.

Benchmark results

These benchmarks were measured on a 2023 MacBook Pro with M3 chip:
BenchmarkTime
Plain-text stream append (steady-state)<0.1 ms
Highlight 50 lines~2 ms
Highlight 500 lines~21 ms
Parse 500 blocks~5 ms
Parse + preprocess 300 blocks~3 ms
These benchmarks reflect typical document sizes. The library is optimized for real-time streaming and interactive editing scenarios.

Plain-text streaming

The <0.1ms plain-text append benchmark measures the fast path for streaming text. When appending simple text to the last paragraph (no new markdown syntax), the view skips reparsing and updates only the trailing block. This optimization enables smooth real-time streaming from LLMs or chat interfaces.

Syntax highlighting

Highlighting 50 lines takes ~2ms, scaling to ~21ms for 500 lines. This is tree-sitter’s parse time plus attribute application.
For comparison, highlight.js (JavaScript-based) typically takes 50-100ms for 500 lines due to JavaScriptCore overhead.

Parsing

Parsing 500 blocks with swift-cmark takes ~5ms. This includes:
  • Math expression preprocessing with regex
  • Cmark parsing with GFM extensions
  • AST conversion to Swift types

Preprocessing

Parsing and preprocessing 300 blocks takes ~3ms. The preprocessing step includes:
  • Syntax highlighting map generation (thread-safe, runs on background queue)
  • Math rendering (requires main thread for UIKit trait access)
  • Image source collection for async loading
See Sources/MarkdownView/MarkdownTextBuilder/PreprocessedContent.swift:12 for the preprocessing implementation.

Tree-sitter vs JavaScript

MarkdownView uses tree-sitter for syntax highlighting instead of JavaScript-based solutions like highlight.js. This provides significant performance benefits:

Architecture comparison

  • Pure native code: No runtime overhead
  • Semantic parsing: Produces a full syntax tree
  • Incremental: Can re-parse only changed regions
  • 19 languages: Pre-compiled grammars included

Performance comparison

OperationTree-sitterHighlight.jsSpeedup
50 lines~2 ms~15-25 ms7-12x
500 lines~21 ms~100-150 ms5-7x
Parser initLazy (on first use)JSContext creationInstant
MemoryNative structsJS heap + bridgeLower
Tree-sitter’s native parsers eliminate the bridge overhead between JavaScript and Swift, making it significantly faster for syntax highlighting.

Lazy parser loading

Language parsers are initialized only when first needed:
Sources/MarkdownView/Components/CodeView/CodeHighlighter.swift
private static let languageFactories: [String: () -> LanguageConfiguration?] = {
    var factories: [String: () -> LanguageConfiguration?] = [:]
    
    func register(_ aliases: [String], _ factory: @escaping () throws -> LanguageConfiguration) {
        let lazyFactory: () -> LanguageConfiguration? = { try? factory() }
        for alias in aliases {
            factories[alias] = lazyFactory
        }
    }
    
    register(["swift"]) {
        try makeConfig(tree_sitter_swift(), name: "Swift")
    }
    register(["python", "py", "python3"]) {
        try makeConfig(tree_sitter_python(), name: "Python")
    }
    // ... 17 more languages
    
    return factories
}()
This design ensures:
1

Fast app launch

No parsers are loaded at startup, keeping initialization time low.
2

Efficient memory use

Only the grammars you actually use are loaded into memory.
3

One-time cost

Each language is initialized once and cached for subsequent code blocks.

Language cache

Sources/MarkdownView/Components/CodeView/CodeHighlighter.swift
private static var resolvedLanguages: [String: LanguageConfiguration] = [:]
private static let resolvedLanguagesLock = NSLock()

private static func languageConfiguration(for alias: String) -> LanguageConfiguration? {
    resolvedLanguagesLock.lock()
    defer { resolvedLanguagesLock.unlock() }
    
    if let cached = resolvedLanguages[alias] {
        return cached // Already initialized
    }
    guard let factory = languageFactories[alias],
          let config = factory() else {
        return nil
    }
    resolvedLanguages[alias] = config // Cache for next time
    return config
}
The cache is thread-safe and shared across all CodeHighlighter instances.

Incremental parsing

When appending text to an existing document, MarkdownView can reuse work from the previous parse:
Sources/MarkdownParser/MarkdownParser/MarkdownParser.swift
public func parseIncremental(
    previousMarkdown: String,
    newMarkdown: String,
    previousBlocks: [MarkdownBlockNode],
    previousRanges: [RootBlockRange]? = nil
) -> IncrementalParseResult? {
    // Find stable prefix that hasn't changed
    let stableRootBlockCount = ...
    let stablePrefixBlockCount = ranges
        .prefix(stableRootBlockCount)
        .reduce(into: 0) { $0 += $1.outputBlockCount }
    
    // Parse only the changed tail
    let tailMarkdown = String(newMarkdown[stablePrefixStartIndex...])
    let tailResult = parse(tailMarkdown)
    
    return .init(
        stablePrefixBlockCount: stablePrefixBlockCount,
        tailResult: tailResult,
        blockRanges: stableRanges + newTailRanges
    )
}

When incremental parsing applies

Incremental parsing is beneficial when:
  • Appending text to the end of a document (streaming)
  • Adding new blocks after existing content
  • Making small edits that don’t affect earlier blocks
var currentMarkdown = "# Title\n\nParagraph one."
var currentBlocks = parser.parse(currentMarkdown).document

// User types more content
let newMarkdown = currentMarkdown + "\n\n**Bold text** in paragraph two."

// Incremental parse reuses the heading and first paragraph
if let result = parser.parseIncremental(
    previousMarkdown: currentMarkdown,
    newMarkdown: newMarkdown,
    previousBlocks: currentBlocks
) {
    // Reuse blocks 0-1, parse only the new paragraph
    let mergedBlocks = currentBlocks.prefix(result.stablePrefixBlockCount) + result.tailResult.document
}

Incremental rendering

The TextBuilder also supports incremental rendering:
Sources/MarkdownView/MarkdownTextBuilder/TextBuilder.swift
func buildIncremental(
    changes: [ASTDiff.Change],
    cachedSegments: [NSAttributedString]
) -> BuildResult {
    for change in changes {
        switch change {
        case let .keep(oldIndex, newIndex):
            // Reuse the previous segment
            let segment = cachedSegments[oldIndex]
            segments.append(segment)
            text.append(segment)
        case let .rebuild(newIndex):
            // Re-render this block
            let segment = processBlock(nodes[newIndex], ...)
            segments.append(segment)
            text.append(segment)
        case .remove:
            // Block was removed
            break
        }
    }
}
Combining incremental parsing with incremental rendering minimizes the work done when content changes.

View pooling

CodeView and TableView instances are expensive to create, so MarkdownView uses a view pool:
public final class ReusableViewProvider {
    private var codeViewPool: [CodeView] = []
    private var tableViewPool: [TableView] = []
    
    func dequeueCodeView() -> CodeView {
        if let view = codeViewPool.popLast() {
            view.prepareForReuse()
            return view
        }
        return CodeView()
    }
    
    func enqueue(_ view: CodeView) {
        codeViewPool.append(view)
    }
}
When rebuilding content, old views are returned to the pool and reused for new blocks. This reduces allocations during scrolling and live updates.

Memory characteristics

The cmark parser is created on-demand and freed after each parse, keeping memory usage low. The parsed AST (Swift enums) is compact and uses value semantics.
Highlight maps are cached using NSCache with a limit of 256 entries. The cache automatically evicts old entries under memory pressure.
private var renderCache: NSCache<NSNumber, HighlightMapBox> = {
    let cache = NSCache<NSNumber, HighlightMapBox>()
    cache.countLimit = 256
    return cache
}()
The ImageLoader uses an in-memory cache for downloaded images to avoid redundant network requests. Images are loaded asynchronously and cached by URL.
The final NSAttributedString can be large for long documents. MarkdownView uses per-block segments to enable partial updates without rebuilding the entire string.

Performance tips

Use incremental parsing

When streaming or appending content, use parseIncremental to avoid reparsing the entire document.

Preprocess on background queue

Syntax highlighting is thread-safe. Generate highlight maps on a background queue before rendering on the main thread.
DispatchQueue.global().async {
    let content = PreprocessedContent(
        parserResult: result,
        theme: theme,
        backgroundSafe: true
    )
    DispatchQueue.main.async {
        let final = content.completeMathRendering(parserResult: result, theme: theme)
        markdownView.setMarkdown(final)
    }
}

Throttle live updates

Use the built-in throttling mechanism to batch rapid updates:
markdownView.throttleInterval = 1 / 20 // 20 FPS

Limit code block size

Very long code blocks (>1000 lines) can slow down syntax highlighting. Consider truncating or paginating extremely large blocks.

Profiling tools

To measure performance in your app:
1

Use Instruments

Profile with Time Profiler to identify hot paths. Look for time spent in cmark_parser_finish (parsing) and highlightWithTreeSitter (highlighting).
2

Add custom signposts

Use os_signpost to measure specific operations:
import os.signpost

let log = OSLog(subsystem: "com.example.app", category: .pointsOfInterest)
os_signpost(.begin, log: log, name: "Parse Markdown")
let result = parser.parse(markdown)
os_signpost(.end, log: log, name: "Parse Markdown")
3

Monitor memory

Use the Memory Graph Debugger to check for leaks or excessive cache growth.
See the architecture overview for how the components work together, and the rendering guide for details on the rendering pipeline.

Build docs developers (and LLMs) love