Skip to main content
MarkdownView is designed for high performance out of the box, but understanding its optimization techniques helps you build fast, responsive applications even with large documents or real-time streaming content.

Performance Overview

MarkdownView achieves excellent performance through several key optimizations:
  • Native C Parsers: Uses tree-sitter’s native parsers instead of JavaScript
  • Lazy Language Loading: Syntax highlighters load only when needed
  • View Recycling: Reuses complex views (code blocks, tables) with object pooling
  • Incremental Parsing: Reparses only changed content
  • Throttled Updates: Rate-limits rendering to maintain smooth framerates
  • Background Processing: Performs parsing and highlighting off the main thread

Benchmark Results

From the official benchmarks (measured on Apple Silicon):
OperationTimeDescription
Plain-text stream append (steady-state)<0.1 msFast path for simple text appends
Highlight 50 lines~2 msSyntax highlighting for medium code block
Highlight 500 lines~21 msSyntax highlighting for large code block
Parse 500 blocks~5 msFull markdown parsing
Parse + preprocess 300 blocks~3 msComplete rendering pipeline
Source: README.md:19-30
These benchmarks demonstrate that MarkdownView can handle real-time streaming content while maintaining 60fps updates.

Throttle Intervals

The throttleInterval property controls how frequently the view updates in response to content changes:
public var throttleInterval: TimeInterval? = 1 / 20  // 20 fps
Location: Sources/MarkdownView/MarkdownTextView/MarkdownTextView.swift:34

How Throttling Works

Throttling prevents excessive rendering when content updates rapidly:
func setupCombine() {
    resetCombine()
    if let throttleInterval {
        contentSubject
            .throttle(for: .seconds(throttleInterval), scheduler: DispatchQueue.main, latest: true)
            .sink { [weak self] content in self?.use(content) }
            .store(in: &cancellables)
    } else {
        contentSubject
            .sink { [weak self] content in self?.use(content) }
            .store(in: &cancellables)
    }
}
Location: Sources/MarkdownView/MarkdownTextView/MarkdownTextView+Private.swift:52-64

Configuring Throttle Intervals

let markdownView = MarkdownTextView()

// High refresh rate for smooth animations (60 fps)
markdownView.throttleInterval = 1.0 / 60.0

// Default balanced rate (20 fps)
markdownView.throttleInterval = 1.0 / 20.0

// Lower rate for battery efficiency (10 fps)
markdownView.throttleInterval = 1.0 / 10.0

// Disable throttling for immediate updates
markdownView.throttleInterval = nil
Disabling throttling (nil) can cause performance issues when content updates rapidly, such as during streaming. Only disable throttling for static content or controlled update scenarios.

Throttling for Streaming Content

When rendering streaming markdown from an API:
class StreamingViewController: UIViewController {
    let markdownView = MarkdownTextView()
    
    func setupStreaming() {
        // Set appropriate throttle for streaming
        markdownView.throttleInterval = 1.0 / 15.0  // 15 fps
    }
    
    func handleStreamChunk(_ chunk: String) {
        currentMarkdown += chunk
        // Update is throttled automatically
        markdownView.setMarkdown(string: currentMarkdown)
    }
}

View Recycling with ReusableViewProvider

Complex views like code blocks and tables are expensive to create. MarkdownView uses an object pool to recycle these views:
public final class ReusableViewProvider {
    private let codeViewPool: ObjectPool<CodeView>
    private let tableViewPool: ObjectPool<TableView>
    
    func acquireCodeView() -> CodeView
    func stashCodeView(_ codeView: CodeView)
    func acquireTableView() -> TableView
    func stashTableView(_ tableView: TableView)
    func reorderViews(matching sequence: [PlatformView])
}
Location: Sources/MarkdownView/MarkdownTextBuilder/ReusableViewProvider.swift:45

How View Recycling Works

  1. Acquisition: When rendering needs a code block or table, it acquires a view from the pool
  2. Configuration: The acquired view is configured with new content
  3. Display: The view is added to the view hierarchy
  4. Stashing: When content changes, unused views are returned to the pool
  5. Reordering: The pool maintains view order to prevent visual glitches

Object Pool Implementation

The internal ObjectPool uses a simple stack-based approach:
private class ObjectPool<T: Equatable & Hashable> {
    private let factory: () -> T
    fileprivate lazy var objects: [T] = .init()
    
    func acquire() -> T {
        if !objects.isEmpty {
            objects.removeLast()
        } else {
            factory()
        }
    }
    
    func stash(_ object: T) {
        objects.append(object)
    }
}
Location: Sources/MarkdownView/MarkdownTextBuilder/ReusableViewProvider.swift:12-31

Custom View Provider

You can provide a custom view provider to control object pooling:
let customProvider = ReusableViewProvider()
let markdownView = MarkdownTextView(viewProvider: customProvider)

// Share the provider across multiple views
let secondView = MarkdownTextView(viewProvider: customProvider)
Sharing a provider reduces memory usage when rendering multiple markdown documents.

Lazy Language Parser Loading

MarkdownView supports 19 programming languages for syntax highlighting, but loading all parsers upfront would waste memory and startup time. Instead, language parsers are loaded lazily:
/// Factory closures that create language configurations on demand.
/// Only the requested language is initialized, avoiding the cost of loading all 15 parsers.
private static let languageFactories: [String: () -> LanguageConfiguration?]
Location: Sources/MarkdownView/Components/CodeView/CodeHighlighter.swift:113

How Lazy Loading Works

  1. Factory Registration: Each language has a factory closure that creates its configuration
  2. First Use: When a code block uses a language, its factory is called
  3. Caching: The created configuration is cached for future use
  4. Lock Protection: A lock ensures thread-safe access to the cache
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
    }
    guard let factory = languageFactories[alias],
          let config = factory() else {
        return nil
    }
    // Cache for all aliases that share this factory
    resolvedLanguages[alias] = config
    return config
}
Location: Sources/MarkdownView/Components/CodeView/CodeHighlighter.swift:189-207

Performance Impact

From the README:
Language parsers are initialized lazily — only the languages actually used are loaded.
Source: README.md:21-22 This means:
  • Documents with no code blocks: No parsers loaded
  • Documents with Swift code: Only Swift parser loaded (~2-3 MB)
  • Mixed languages: Only those languages are loaded
If your app renders many documents with the same languages, the lazy loading cache ensures parsers are initialized only once per app session.

Background Processing

MarkdownView performs expensive operations on background queues to keep the UI responsive:
static let preprocessingQueue = DispatchQueue(
    label: "com.markdownview.preprocessing",
    qos: .userInitiated
)
Location: Sources/MarkdownView/MarkdownTextView/MarkdownTextView+Private.swift:38-41

What Runs in the Background

  1. Markdown Parsing: Converting raw markdown to AST
  2. Syntax Highlighting: Tree-sitter parsing and color mapping
  3. Content Preprocessing: Preparing attributed strings

What Runs on Main Thread

  1. Math Rendering: Requires UIKit trait collection access
  2. View Updates: UIKit/AppKit operations
  3. Layout: Frame calculations and view positioning

Background Processing Pipeline

func setupRawCombine() {
    pipeline
        .map { [weak self] markdown -> RawMarkdownUpdate in
            // Fast path check (main thread)
            return .parse(markdown: markdown, theme: self.theme, incrementalContext: ...)
        }
        .receive(on: Self.preprocessingQueue)
        .map { update -> RawMarkdownUpdate in
            // Parsing and highlighting (background)
            let result = parser.parse(markdown)
            let content = PreprocessedContent(parserResult: result, theme: theme, backgroundSafe: true)
            return .parsedFull(result: result, content: content, ...)
        }
        .receive(on: DispatchQueue.main)
        .sink { [weak self] update in
            // Math rendering and view update (main thread)
            let finalContent = content.completeMathRendering(parserResult: result, theme: theme)
            self.use(finalContent)
        }
}
Location: Sources/MarkdownView/MarkdownTextView/MarkdownTextView+Private.swift:66-165
The backgroundSafe: true parameter tells PreprocessedContent to defer math rendering until the main thread. Code highlighting is thread-safe and runs immediately.

Incremental Rendering

When content updates, MarkdownView uses AST diffing to minimize view updates:
private func makeIncrementalRenderContext() -> IncrementalRenderContext? {
    guard let lastBuildResult else { return nil }
    guard !lastRenderedBlocks.isEmpty else { return nil }
    guard lastRenderedBlocks != document.blocks else { return nil }
    
    let changes = ASTDiff.diff(old: lastRenderedBlocks, new: document.blocks)
    // ...
}
Location: Sources/MarkdownView/MarkdownTextView/MarkdownTextView+Update.swift:18-30

Preserved View Optimization

Views corresponding to unchanged blocks are preserved:
for change in changes {
    guard case let .keep(oldIndex, _) = change else { continue }
    guard oldIndex < lastBuildResult.blockSegments.count else { continue }
    for preservedView in TextBuilder.contextViews(in: lastBuildResult.blockSegments[oldIndex]) {
        preservedViewIDs.insert(ObjectIdentifier(preservedView))
    }
}
Location: Sources/MarkdownView/MarkdownTextView/MarkdownTextView+Update.swift:32-39 Preserved views skip the reconfiguration step and remain in the view hierarchy.

Syntax Highlighting Cache

Code highlighting results are cached using NSCache:
private var renderCache: NSCache<NSNumber, HighlightMapBox> = {
    let cache = NSCache<NSNumber, HighlightMapBox>()
    cache.countLimit = 256
    return cache
}()
Location: Sources/MarkdownView/Components/CodeView/CodeHighlighter.swift:46-50 The cache key is a hash of the code content and language:
public func key(for content: String, language: String?) -> Int {
    var hasher = Hasher()
    hasher.combine(content)
    hasher.combine(language?.lowercased() ?? "")
    return hasher.finalize()
}
Location: Sources/MarkdownView/Components/CodeView/CodeHighlighter.swift:322-327 This ensures:
  • Identical code blocks share highlighting results
  • Cache automatically evicts old entries (256 block limit)
  • Memory usage remains bounded

Fast Path Optimizations

Plain Text Append

For streaming plain text (no markdown syntax), MarkdownView skips parsing entirely:
func makePlainTextAppendFastPath(for markdown: String) -> PreprocessedContent? {
    guard let lastRawMarkdown else { return nil }
    guard markdown.count > lastRawMarkdown.count else { return nil }
    guard markdown.hasPrefix(lastRawMarkdown) else { return nil }
    
    let appendedText = String(markdown.dropFirst(lastRawMarkdown.count))
    guard Self.isSafePlainTextAppend(appendedText) else { return nil }
    // ...
}
Location: Sources/MarkdownView/MarkdownTextView/MarkdownTextView+Private.swift:178-206 This fast path achieves <0.1ms updates for plain text streaming.

Disallowed Characters

The fast path rejects text containing markdown syntax:
private static let disallowedPlainTextAppendScalars = CharacterSet(
    charactersIn: "\n\r`*_[]()!$<>|~\\"
)
Location: Sources/MarkdownView/MarkdownTextView/MarkdownTextView+Private.swift:43-45

Best Practices

1. Use Appropriate Throttle Intervals

// Streaming content
markdownView.throttleInterval = 1.0 / 15.0

// Static content (no throttling needed)
markdownView.throttleInterval = nil

// Real-time collaboration
markdownView.throttleInterval = 1.0 / 30.0

2. Reuse MarkdownTextView Instances

// Good: Reuse the same view
markdownView.setMarkdown(string: newContent)

// Bad: Creating new views repeatedly
let markdownView = MarkdownTextView()  // Don't do this in a loop

3. Share ReusableViewProvider

let sharedProvider = ReusableViewProvider()

let view1 = MarkdownTextView(viewProvider: sharedProvider)
let view2 = MarkdownTextView(viewProvider: sharedProvider)
// Both views share recycled code blocks and tables

4. Batch Content Updates

// Good: Single update with complete content
let fullContent = chunks.joined()
markdownView.setMarkdown(string: fullContent)

// Bad: Multiple rapid updates
for chunk in chunks {
    markdownView.setMarkdown(string: currentContent + chunk)  // Triggers throttling
}

5. Use PreprocessedContent for Known Content

When displaying the same content multiple times:
// Parse once
let parser = MarkdownParser()
let result = parser.parse(staticMarkdown)
let content = MarkdownTextView.PreprocessedContent(
    parserResult: result,
    theme: .default
)

// Reuse preprocessed content
markdownView1.setMarkdown(content)
markdownView2.setMarkdown(content)

6. Profile with Instruments

Use Xcode Instruments to identify bottlenecks:
  • Time Profiler: Find slow methods
  • Allocations: Track memory usage
  • System Trace: Analyze thread behavior

7. Monitor Memory Usage

For very large documents:
class LargeDocumentViewController: UIViewController {
    func handleMemoryWarning() {
        // Reset view provider to free pooled views
        viewProvider.removeAll()
    }
}

8. Consider Document Pagination

For extremely large documents (>10,000 blocks), consider pagination:
func loadPage(_ pageNumber: Int) {
    let startBlock = pageNumber * blocksPerPage
    let endBlock = min(startBlock + blocksPerPage, totalBlocks)
    let pageBlocks = allBlocks[startBlock..<endBlock]
    
    // Render only visible page
    markdownView.setMarkdown(PreprocessedContent(
        blocks: Array(pageBlocks),
        rendered: pageRendered,
        highlightMaps: pageHighlights
    ))
}

Performance Monitoring

Add performance logging to track rendering times:
import os.log

class PerformanceMonitor {
    static let log = Logger(subsystem: "MyApp", category: "Performance")
    
    func measureRender(_ block: () -> Void) {
        let start = CFAbsoluteTimeGetCurrent()
        block()
        let elapsed = (CFAbsoluteTimeGetCurrent() - start) * 1000
        Self.log.info("Render time: \(elapsed, format: .fixed(precision: 2))ms")
    }
}

// Usage
performanceMonitor.measureRender {
    markdownView.setMarkdown(string: largeDocument)
}

See Also

Build docs developers (and LLMs) love