Performance Optimization
This guide covers performance optimization techniques for Markput applications.
Performance Overview
Section titled “Performance Overview”Baseline Performance
Section titled “Baseline Performance”Markput is optimized for typical use cases:
| Scenario | Performance | Notes |
|---|---|---|
| Text length | 100-10,000 chars | Excellent performance |
| Marks count | 10-100 marks | Fast rendering |
| Typing speed | Normal typing | No lag |
| Parse time | ~0.01ms per 100 chars | Very fast |
| Re-render | ~1-5ms | Optimized with memoization |
Performance Bottlenecks
Section titled “Performance Bottlenecks”Common performance issues:
- Large documents (>10,000 characters)
- Many marks (>100 marks)
- Complex mark components (heavy rendering)
- Frequent re-renders (missing memoization)
- Heavy onChange handlers (blocking updates)
Large Documents
Section titled “Large Documents”Problem: Slow Parsing
Section titled “Problem: Slow Parsing”Symptoms:
- Typing feels sluggish
- Delays when pasting large text
- UI freezes on input
Solution 1: Debounce onChange
import { useMemo } from 'react'import { debounce } from 'lodash'
function Editor() { const [value, setValue] = useState('')
// Debounce expensive operations const debouncedSave = useMemo( () => debounce((value: string) => { saveToBackend(value) }, 500), [] )
const handleChange = (newValue: string) => { setValue(newValue) // Update immediately (fast) debouncedSave(newValue) // Save later (slow) }
return ( <MarkedInput value={value} onChange={handleChange} Mark={MyMark} /> )}Solution 2: Virtualization
For very large documents, use virtualization:
import { FixedSizeList } from 'react-window'
function VirtualizedEditor() { const [value, setValue] = useState('') const lines = value.split('\n')
return ( <FixedSizeList height={600} itemCount={lines.length} itemSize={24} width="100%" > {({ index, style }) => ( <div style={style}> <MarkedInput value={lines[index]} onChange={(newLine) => { const newLines = [...lines] newLines[index] = newLine setValue(newLines.join('\n')) }} Mark={MyMark} /> </div> )} </FixedSizeList> )}Solution 3: Lazy Parsing
Parse only visible content:
function LazyEditor() { const [value, setValue] = useState('') const [visibleRange, setVisibleRange] = useState({ start: 0, end: 1000 })
const visibleText = value.substring(visibleRange.start, visibleRange.end)
return ( <ScrollContainer onScroll={(range) => setVisibleRange(range)}> <MarkedInput value={visibleText} onChange={(newText) => { const newValue = value.substring(0, visibleRange.start) + newText + value.substring(visibleRange.end) setValue(newValue) }} Mark={MyMark} /> </ScrollContainer> )}Problem: Slow Re-rendering
Section titled “Problem: Slow Re-rendering”Solution: Memoize Mark Components
import { memo } from 'react'
// ❌ Re-renders on every changeconst SlowMark: FC<MarkProps> = ({ value }) => ( <span>{value}</span>)
// ✅ Only re-renders when props changeconst FastMark = memo<MarkProps>(({ value }) => ( <span>{value}</span>))
<MarkedInput Mark={FastMark} />Solution: Memoize Options
Core compares options by ELEMENT IDENTITY, so a fresh array holding the same
option objects costs nothing — hoisting the objects out of the render is enough,
and a useMemo over the whole array is not required. What still costs is fresh
option OBJECTS, because nothing can tell them from a genuine change.
// ❌ New option objects on every render — the resolver churns and every token re-rendersfunction Editor() { return <MarkedInput Mark={MyMark} options={[{markup: '@[__value__]'}]} />}
// ✅ The objects are stable; the array around them may be freshconst OPTIONS = [{markup: '@[__value__]'}]
function Editor() { return <MarkedInput Mark={MyMark} options={OPTIONS} />}The parser is defended separately and unconditionally: it is re-created only when the MARKUP STRINGS change, so even the ❌ form above never re-mints token ids. The cost it leaves is a render pass, not a remount.
Many Marks
Section titled “Many Marks”Problem: Too Many DOM Nodes
Section titled “Problem: Too Many DOM Nodes”Symptoms:
- Slow scrolling
- High memory usage
- Browser becomes unresponsive
Solution: Simplify Mark Components
// ❌ Heavy mark componentconst HeavyMark: FC<MarkProps> = ({ value, meta }) => { const user = useFetchUser(meta) // API call per mark! const avatar = useFetchAvatar(user.id) // Another API call!
return ( <div className="mark"> <img src={avatar} /> <span>{value}</span> <Tooltip content={user.bio} /> </div> )}
// ✅ Lightweight mark componentconst LightMark: FC<MarkProps> = ({ value }) => ( <span className="mark">{value}</span>)Solution: Batch Data Fetching
// Fetch all user data at oncefunction Editor() { const [value, setValue] = useState('') const marks = extractMarks(value) const userIds = marks.map(m => m.meta)
// Single batch request const users = useFetchUsers(userIds)
const options = useMemo(() => [{ markup: '@[__value__](__meta__)', slotProps: { mark: ({ value, meta }: MarkProps) => ({ value, user: users[meta] // Pass cached data }) } }], [users])
return <MarkedInput value={value} onChange={setValue} options={options} />}Problem: Expensive Mark Rendering
Section titled “Problem: Expensive Mark Rendering”Solution: Lazy Loading
const LazyMark: FC<MarkProps> = ({ value, meta }) => { const markerRef = useRef<HTMLSpanElement>(null) const [data, setData] = useState(null) const isVisible = useIntersectionObserver(markerRef)
useEffect(() => { if (isVisible && !data) { fetchData(meta).then(setData) } }, [isVisible, meta])
return ( <span ref={markerRef}> {data ? <DetailedView data={data} /> : value} </span> )}Memoization Strategies
Section titled “Memoization Strategies”Strategy 1: Component-Level Memoization
Section titled “Strategy 1: Component-Level Memoization”// Memoize entire mark componentconst MemoizedMark = memo<MarkProps>( ({ value, meta }) => <span>{value}</span>, (prev, next) => { // Custom comparison return prev.value === next.value && prev.meta === next.meta })Strategy 2: Hook-Level Memoization
Section titled “Strategy 2: Hook-Level Memoization”const MyMark: FC = () => { const mark = useMark() const value = mark.value() const meta = mark.meta()
// Memoize expensive computations const displayName = useMemo(() => { return formatName(value) // Expensive operation }, [value])
const userLink = useMemo(() => { return `/users/${meta}` }, [meta])
return <a href={userLink}>{displayName}</a>}Strategy 3: Data-Level Memoization
Section titled “Strategy 3: Data-Level Memoization”// Cache parsed tokensconst tokens = useMemo(() => { return parser.parse(value)}, [value, parser])
// Cache mark dataconst markData = useMemo(() => { return tokens.filter(t => t.type === 'mark').map(t => ({value: t.value, meta: t.meta}))}, [tokens])Strategy 4: Callback Memoization
Section titled “Strategy 4: Callback Memoization”function Editor() { // ❌ New function on every render const handleMarkClick = (id: string) => { console.log(id) }
// ✅ Memoized callback const handleMarkClick = useCallback((id: string) => { console.log(id) }, [])
return <MarkedInput Mark={MyMark} />}Debouncing
Section titled “Debouncing”Debounce onChange
Section titled “Debounce onChange”import { useMemo } from 'react'import debounce from 'lodash/debounce'
function Editor() { const [value, setValue] = useState('')
const debouncedOnChange = useMemo( () => debounce((newValue: string) => { // Heavy operations: API calls, validation, etc. saveToServer(newValue) validateContent(newValue) updateAnalytics(newValue) }, 300), [] )
const handleChange = (newValue: string) => { setValue(newValue) // Immediate update (UI) debouncedOnChange(newValue) // Delayed operations }
return ( <MarkedInput value={value} onChange={handleChange} Mark={MyMark} /> )}Debounce Overlay Search
Section titled “Debounce Overlay Search”const MyOverlay: FC = () => { const { match } = useOverlay() const [results, setResults] = useState([])
const debouncedSearch = useMemo( () => debounce((query: string) => { searchAPI(query).then(setResults) }, 200), [] )
useEffect(() => { debouncedSearch(match.value) }, [match.value])
return <div>{results.map(r => ...)}</div>}Throttle vs Debounce
Section titled “Throttle vs Debounce”// Debounce: Wait for user to stop typingconst debounced = debounce(fn, 300)// Calls fn 300ms after last keystroke
// Throttle: Call at most once per intervalconst throttled = throttle(fn, 300)// Calls fn at most every 300msWhen to use:
- Debounce: API calls, validation, save operations
- Throttle: Scroll events, resize events, frequent updates
Profiling
Section titled “Profiling”React DevTools Profiler
Section titled “React DevTools Profiler”- Install React DevTools extension
- Open DevTools → Profiler tab
- Click “Record”
- Type in editor
- Stop recording
- Analyze flame graph
Look for:
- Long render times (>16ms)
- Frequent re-renders
- Unnecessary component updates
Chrome Performance Tab
Section titled “Chrome Performance Tab”- Open DevTools → Performance tab
- Click “Record”
- Perform actions in editor
- Stop recording
- Analyze timeline
Look for:
- Long scripting time
- Layout thrashing
- Excessive repaints
Custom Performance Monitoring
Section titled “Custom Performance Monitoring”function measurePerformance<T>(fn: () => T, label: string): T { const start = performance.now() const result = fn() const end = performance.now() console.log(`[${label}] ${(end - start).toFixed(2)}ms`) return result}
// Usageconst tokens = measurePerformance(() => parser.parse(value), 'Parse')Performance Hooks
Section titled “Performance Hooks”function usePerformanceMonitor(label: string) { useEffect(() => { const start = performance.now() return () => { const end = performance.now() console.log(`[${label}] Render: ${(end - start).toFixed(2)}ms`) } })}
function MyMark() { usePerformanceMonitor('MyMark') // ... component code}Bundle Size Optimization
Section titled “Bundle Size Optimization”Tree Shaking
Section titled “Tree Shaking”Ensure proper tree shaking:
// ✅ Named imports (tree-shakeable)import {MarkedInput, useMark} from '@markput/react'
// ❌ Namespace import (not tree-shakeable)import * as Markput from '@markput/react'Code Splitting
Section titled “Code Splitting”Split large editors into separate chunks:
import { lazy, Suspense } from 'react'
const AdvancedEditor = lazy(() => import('./AdvancedEditor'))
function App() { return ( <Suspense fallback={<div>Loading...</div>}> <AdvancedEditor /> </Suspense> )}Dynamic Imports
Section titled “Dynamic Imports”Load mark components on demand:
function App() { const [MarkComponent, setMarkComponent] = useState(null)
useEffect(() => { import('./HeavyMark').then(module => { setMarkComponent(() => module.HeavyMark) }) }, [])
if (!MarkComponent) { return <div>Loading...</div> }
return <MarkedInput Mark={MarkComponent} />}Memory Management
Section titled “Memory Management”Cleanup Event Listeners
Section titled “Cleanup Event Listeners”function MyComponent() { useEffect(() => { const handler = e => console.log(e) store.bus.on(SystemEvent.Change, handler)
return () => { store.bus.off(SystemEvent.Change, handler) // ✅ Cleanup } }, [])}Avoid Memory Leaks
Section titled “Avoid Memory Leaks”// ❌ Memory leak: closure captures large objectfunction Editor() { const largeData = fetchLargeData()
const handleChange = (value: string) => { console.log(largeData) // Captures largeData forever! }
return <MarkedInput onChange={handleChange} />}
// ✅ Fixed: only capture what you needfunction Editor() { const largeData = fetchLargeData() const summary = largeData.summary // Small object
const handleChange = (value: string) => { console.log(summary) // Only captures summary }
return <MarkedInput onChange={handleChange} />}WeakMap for Caches
Section titled “WeakMap for Caches”// Cache mark data without preventing GC. Key on the live node: `useMark()` returns// the same `MarkNode` object for as long as the mark keeps its id.const markCache = new WeakMap<MarkNode, CachedData>()
function getCachedData(mark: MarkNode): CachedData { if (markCache.has(mark)) { return markCache.get(mark)! }
const data = computeExpensiveData(mark) markCache.set(mark, data) return data}Real-World Optimizations
Section titled “Real-World Optimizations”Optimization 1: Batch Updates
Section titled “Optimization 1: Batch Updates”// ❌ Multiple onChange callsfunction insertMultipleMarks() { marks.forEach(mark => { const newValue = value + annotate(markup, mark) onChange(newValue) // Triggers re-render each time! })}
// ✅ Single onChange callfunction insertMultipleMarks() { let newValue = value marks.forEach(mark => { newValue += annotate(markup, mark) }) onChange(newValue) // Single re-render}Optimization 2: Request Deduplication
Section titled “Optimization 2: Request Deduplication”const pendingRequests = new Map<string, Promise<any>>()
function fetchWithDedup(url: string): Promise<any> { if (pendingRequests.has(url)) { return pendingRequests.get(url)! }
const promise = fetch(url).then(r => r.json()) pendingRequests.set(url, promise)
promise.finally(() => { pendingRequests.delete(url) })
return promise}Optimization 3: Incremental Rendering
Section titled “Optimization 3: Incremental Rendering”function IncrementalEditor() { const [value, setValue] = useState('') const [rendered, setRendered] = useState('')
useEffect(() => { // Render in chunks to avoid blocking const chunks = chunkText(value, 1000) let currentChunk = 0
const timer = setInterval(() => { if (currentChunk < chunks.length) { setRendered(prev => prev + chunks[currentChunk]) currentChunk++ } else { clearInterval(timer) } }, 16) // ~60fps
return () => clearInterval(timer) }, [value])
return <MarkedInput value={rendered} />}Performance Checklist
Section titled “Performance Checklist”✅ Must Do
Section titled “✅ Must Do”- Memoize Mark components with
memo() - Memoize options array with
useMemo() - Debounce expensive onChange operations
- Use
useCallbackfor event handlers - Clean up event listeners in
useEffect
✅ Should Do
Section titled “✅ Should Do”- Profile with React DevTools
- Minimize mark component complexity
- Batch API requests for mark data
- Use stable keys for rendered marks
- Implement lazy loading for heavy marks
✅ Consider For Large Apps
Section titled “✅ Consider For Large Apps”- Implement virtualization for long documents
- Use code splitting for large editors
- Implement request deduplication
- Use WeakMap for caches
- Consider Web Workers for parsing
Performance Benchmarks
Section titled “Performance Benchmarks”Test Setup
Section titled “Test Setup”function benchmark(label: string, fn: () => void, iterations = 1000) { const start = performance.now()
for (let i = 0; i < iterations; i++) { fn() }
const end = performance.now() const avg = (end - start) / iterations console.log(`[${label}] Avg: ${avg.toFixed(3)}ms`)}Parsing Benchmarks
Section titled “Parsing Benchmarks”const parser = new Parser(['@[__value__](__meta__)'])
benchmark('Parse 100 chars', () => { parser.parse('Hello @[Alice](1) @[Bob](2)')})
benchmark('Parse 1000 chars', () => { parser.parse(longText)})Rendering Benchmarks
Section titled “Rendering Benchmarks”benchmark('Render 10 marks', () => { render( <MarkedInput value={textWith10Marks} Mark={MyMark} /> )})Common Performance Issues
Section titled “Common Performance Issues”Issue 1: Flickering on Type
Section titled “Issue 1: Flickering on Type”Cause: Re-parsing on every keystroke
Solution: Debounce or use controlled input
Issue 2: Slow Overlay
Section titled “Issue 2: Slow Overlay”Cause: Heavy filtering/searching on every character
Solution: Debounce search, limit results
Issue 3: Memory Leak
Section titled “Issue 3: Memory Leak”Cause: Event listeners not cleaned up
Solution: Always clean up in useEffect
Issue 4: Laggy Scrolling
Section titled “Issue 4: Laggy Scrolling”Cause: Too many DOM nodes
Solution: Virtualization or pagination