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):
| Operation | Time | Description |
|---|
| Plain-text stream append (steady-state) | <0.1 ms | Fast path for simple text appends |
| Highlight 50 lines | ~2 ms | Syntax highlighting for medium code block |
| Highlight 500 lines | ~21 ms | Syntax highlighting for large code block |
| Parse 500 blocks | ~5 ms | Full markdown parsing |
| Parse + preprocess 300 blocks | ~3 ms | Complete 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
- Acquisition: When rendering needs a code block or table, it acquires a view from the pool
- Configuration: The acquired view is configured with new content
- Display: The view is added to the view hierarchy
- Stashing: When content changes, unused views are returned to the pool
- 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
- Factory Registration: Each language has a factory closure that creates its configuration
- First Use: When a code block uses a language, its factory is called
- Caching: The created configuration is cached for future use
- 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
- Markdown Parsing: Converting raw markdown to AST
- Syntax Highlighting: Tree-sitter parsing and color mapping
- Content Preprocessing: Preparing attributed strings
What Runs on Main Thread
- Math Rendering: Requires UIKit trait collection access
- View Updates: UIKit/AppKit operations
- 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()
}
}
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