is trusted or sanitized; use DocumentFragment when you need to attach event listeners or preserve element references before insertion.
User input events fire at unpredictable frequencies. Processing every input, scroll, or resize event synchronously blocks the main thread. Scheduling utilities decouple event reception from execution.
type SchedulerFn = (...args: any[]) => void;
class InputScheduler {
public static debounce(fn: SchedulerFn, delay: number): SchedulerFn {
let timeoutId: ReturnType<typeof setTimeout> | null = null;
return (...args: any[]) => {
if (timeoutId) clearTimeout(timeoutId);
timeoutId = setTimeout(() => fn(...args), delay);
};
}
public static throttle(fn: SchedulerFn, interval: number): SchedulerFn {
let lastExecution = 0;
return (...args: any[]) => {
const now = performance.now();
if (now - lastExecution >= interval) {
lastExecution = now;
fn(...args);
}
};
}
}
Architecture Rationale: debounce delays execution until the event stream pauses, ideal for search queries or form validation. throttle guarantees execution at fixed intervals, preserving responsiveness for scroll or resize handlers. Using performance.now() instead of Date.now() provides sub-millisecond precision, critical for consistent frame pacing.
Phase 3: Viewport-Aware List Rendering
Rendering thousands of DOM nodes exhausts memory and cripples scrolling. Virtualization calculates which items intersect the visible viewport and only renders those.
class ViewportRenderer {
private container: HTMLElement;
private itemHeight: number;
private totalItems: number;
private renderFn: (index: number) => HTMLElement;
constructor(config: {
containerId: string;
itemHeight: number;
totalItems: number;
renderFn: (index: number) => HTMLElement;
}) {
this.container = document.getElementById(config.containerId)!;
this.itemHeight = config.itemHeight;
this.totalItems = config.totalItems;
this.renderFn = config.renderFn;
this.container.style.overflow = 'auto';
this.container.style.height = '400px';
this.container.addEventListener('scroll', this.handleScroll.bind(this));
}
private handleScroll(): void {
const scrollTop = this.container.scrollTop;
const visibleCount = Math.ceil(this.container.clientHeight / this.itemHeight);
const startIndex = Math.floor(scrollTop / this.itemHeight);
const endIndex = Math.min(startIndex + visibleCount + 2, this.totalItems);
this.container.innerHTML = '';
const spacer = document.createElement('div');
spacer.style.height = `${this.totalItems * this.itemHeight}px`;
spacer.style.position = 'relative';
for (let i = startIndex; i < endIndex; i++) {
const item = this.renderFn(i);
item.style.position = 'absolute';
item.style.top = `${i * this.itemHeight}px`;
item.style.height = `${this.itemHeight}px`;
item.style.width = '100%';
spacer.appendChild(item);
}
this.container.appendChild(spacer);
}
}
Architecture Rationale: The spacer div maintains scroll height while absolutely positioned items render only visible rows. The +2 buffer prevents flickering during rapid scrolling. Fixed heights are mandatory; variable heights require a secondary measurement pass or a library like tanstack-virtual that caches row dimensions.
Phase 4: Computational Offloading
Heavy calculations block the event loop, freezing UI interactions. Web Workers run in isolated threads with no DOM access, making them ideal for data processing.
class TaskOffloader {
private worker: Worker;
constructor(workerScript: string) {
this.worker = new Worker(workerScript);
}
public execute<T>(payload: any): Promise<T> {
return new Promise((resolve, reject) => {
this.worker.onmessage = (e: MessageEvent) => resolve(e.data);
this.worker.onerror = (err) => reject(err);
this.worker.postMessage(payload);
});
}
public terminate(): void {
this.worker.terminate();
}
}
Architecture Rationale: Workers communicate via structured cloning. Large payloads should be transferred using postMessage(data, [transferable]) to avoid memory duplication. Always wrap worker calls in Promises for clean async/await consumption. Terminate workers when tasks complete to free thread resources.
Phase 5: State Caching & Memoization
Expensive pure functions benefit from result caching. However, naive caching causes memory leaks. Production memoization requires eviction strategies.
class MemoCache {
private store: Map<string, any>;
private maxSize: number;
constructor(maxSize: number = 100) {
this.store = new Map();
this.maxSize = maxSize;
}
public getOrCompute<T>(key: string, compute: () => T): T {
if (this.store.has(key)) {
const value = this.store.get(key)!;
this.store.delete(key);
this.store.set(key, value);
return value;
}
const result = compute();
if (this.store.size >= this.maxSize) {
const oldestKey = this.store.keys().next().value;
this.store.delete(oldestKey);
}
this.store.set(key, result);
return result;
}
}
Architecture Rationale: Map preserves insertion order, enabling LRU (Least Recently Used) eviction by deleting the first key when capacity is reached. Moving accessed keys to the end maintains recency tracking. For object keys, serialize deterministically or use WeakMap when keys are DOM nodes or class instances to allow garbage collection.
Optimization without measurement is guesswork. The Performance API provides high-resolution timing and DevTools integration.
class PerfMonitor {
public static mark(label: string): void {
performance.mark(label);
}
public static measure(label: string, startMark: string, endMark: string): PerformanceMeasure | null {
try {
return performance.measure(label, startMark, endMark);
} catch {
return null;
}
}
public static logMemory(): void {
if (performance.memory) {
const mb = (performance.memory.usedJSHeapSize / 1048576).toFixed(2);
console.log(`[Perf] Heap: ${mb} MB`);
}
}
}
Architecture Rationale: performance.mark and performance.measure appear in Chrome DevTools' Performance tab, enabling visual correlation with frames and network requests. performance.memory is Chromium-only; wrap it in feature detection. Always clear marks after measurement to prevent memory bloat in long-running sessions.
Pitfall Guide
1. Layout Thrashing via Mixed Reads/Writes
Explanation: Reading a layout property (offsetWidth, getBoundingClientRect()) forces synchronous reflow. Writing immediately after triggers another. Chaining these in loops multiplies reflow costs.
Fix: Separate reads and writes into distinct phases. Read all dimensions first, compute positions, then apply styles in a single batch.
2. Over-Batching DOM Updates
Explanation: Batching 500+ elements before flushing delays visual feedback, causing perceived lag. Users expect incremental updates for interactive lists.
Fix: Batch in chunks of 50β100 elements using requestIdleCallback or setTimeout(fn, 0) to yield to the main thread between batches.
3. Memoization Without Cache Eviction
Explanation: Unbounded caches grow until the browser crashes. JSON.stringify on large objects also consumes CPU and memory.
Fix: Implement LRU eviction, set explicit size limits, and avoid caching non-deterministic or side-effect-heavy functions.
Explanation: Throttling scroll to 200ms+ causes janky parallax or sticky header behavior. The browser expects scroll handlers to complete within 10ms.
Fix: Use passive: true event listeners, throttle at 16ms (60fps), and offload heavy calculations to requestIdleCallback.
5. Virtualizing Without Fixed Dimensions
Explanation: Virtual lists assume uniform item heights. Variable heights break offset calculations, causing misaligned content and scroll jumps.
Fix: Use a measurement pass to cache row heights, or adopt a library that supports dynamic sizing with a fallback grid layout.
6. Using setInterval for Animations
Explanation: setInterval drifts from the display refresh rate, causing frame drops and unnecessary CPU wake-ups when the tab is hidden.
Fix: Always use requestAnimationFrame. It syncs with the compositor thread and automatically pauses in background tabs.
7. Blocking the Main Thread During Worker Initialization
Explanation: Creating workers synchronously during app startup adds to initial load time. Large worker scripts delay Time to Interactive.
Fix: Initialize workers lazily or preload them using <link rel="preload" as="worker">. Pool workers for repeated tasks to avoid recreation overhead.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Search input with API calls | Debounce (300ms) | Prevents request flooding; waits for user intent | Reduces server load by 70β90% |
| Scroll-based parallax/sticky headers | Throttle (16ms) + passive listener | Maintains 60fps responsiveness; avoids input lag | Minimal CPU overhead |
| Processing 10k+ records | Web Worker + Transferable objects | Keeps main thread free for UI; avoids GC pauses | Higher initial memory, smoother UX |
| Rendering 500+ list items | Virtual scrolling | Cuts DOM nodes from 500 to ~20; stabilizes memory | Requires fixed heights or measurement cache |
| Repeated pure function calls | LRU Memoization | Avoids redundant computation; bounded memory | Trade-off: memory for CPU cycles |
| Complex CSS animations | requestAnimationFrame + transform/opacity | Compositor-only properties avoid layout/paint | 40β60% less main thread work |
Configuration Template
// perf.config.ts
export const PerformanceConfig = {
dom: {
batchSize: 75,
useFragment: true,
sanitizeHTML: true
},
input: {
debounceDelay: 300,
throttleInterval: 16,
passiveListeners: true
},
virtualization: {
itemHeight: 48,
bufferRows: 2,
containerHeight: 400
},
workers: {
poolSize: navigator.hardwareConcurrency || 4,
timeout: 5000,
transferable: true
},
cache: {
maxSize: 150,
eviction: 'LRU',
ttl: 300000 // 5 minutes
},
monitoring: {
enableMarks: true,
logMemory: false, // Chromium only
sampleRate: 1.0
}
};
Quick Start Guide
- Install instrumentation: Replace all
console.time calls with PerfMonitor.mark() and PerfMonitor.measure(). Open Chrome DevTools β Performance tab to visualize frame budgets.
- Wrap event listeners: Apply
InputScheduler.debounce to search/autocomplete inputs and InputScheduler.throttle to scroll/resize handlers. Add { passive: true } to scroll listeners.
- Replace list rendering: Swap full-array DOM insertion with
ViewportRenderer. Ensure items have fixed heights or implement a measurement pass for dynamic sizing.
- Offload heavy tasks: Identify functions exceeding 50ms execution time. Move them to a
TaskOffloader instance. Use postMessage with transferable arrays for large datasets.
- Validate and iterate: Run Lighthouse CI on staging. Target INP < 200ms, LCP < 2.5s, and CLS < 0.1. Adjust batch sizes and cache limits based on telemetry.