Debug Performance Issues
Guide for investigating and resolving performance problems in the grid component.
Step 1: Identify the Symptom
Common performance issues:
- •Slow scrolling — too much work in scroll handler or virtualization
- •Slow initial render — too many DOM nodes created upfront
- •Laggy interactions — expensive event handlers blocking the main thread
- •Memory leaks — listeners or DOM nodes not cleaned up
- •Layout thrashing — reading then writing DOM in loops
Step 2: Profile
e th### Playwright Trace Capture (Primary Method)
Playwright captures identical data to Chrome DevTools Performance tab, but scriptable and CI-friendly:
// In a Playwright test or standalone script
import { chromium } from '@playwright/test';
const browser = await chromium.launch();
const page = await browser.newPage();
// Start Chrome trace (same data as DevTools Performance tab)
await page.tracing.start({ screenshots: true, categories: ['devtools.timeline'] });
await page.goto('http://localhost:4400'); // Storybook or demo app
// ... reproduce the performance issue ...
await page.tracing.stop({ path: 'trace.json' });
await browser.close();
Then analyze with the project's trace analyzer:
# Extract long tasks, layout thrashing, forced reflows, scroll bottlenecks node scripts/analyze-trace.mjs trace.json
The scripts/analyze-trace.mjs script reports:
- •Top 20 long tasks (>50ms)
- •Layout/style recalculation frequency and cost
- •Forced reflows (layout thrashing with stack traces)
- •Scroll-related performance bottlenecks
You can also open trace.json in Chrome DevTools manually: DevTools → Performance → Load profile.
E2E Performance Regression Tests
The project has a comprehensive e2e performance test suite at e2e/tests/performance-regression.spec.ts (700+ lines). Use it to:
# Run all performance tests bun nx e2e e2e --grep="Performance" # Run a specific test bun nx e2e e2e --grep="scroll performance"
These tests enforce budgets for initial render, scrolling, sorting, filtering, and editing — and fail CI if a regression is detected.
Performance API in Unit Tests
Use performance.mark() / performance.measure() in Vitest tests for micro-benchmarks:
import { describe, it, expect } from 'vitest';
it('should render 10k rows within budget', async () => {
const grid = document.createElement('tbw-grid');
document.body.appendChild(grid);
await waitUpgrade(grid);
performance.mark('render-start');
grid.rows = generateRows(10000);
await grid.ready();
performance.mark('render-end');
performance.measure('render-time', 'render-start', 'render-end');
const duration = performance.getEntriesByName('render-time')[0].duration;
expect(duration).toBeLessThan(100); // ms budget
});
Browser DevTools (Manual)
- •Open Chrome DevTools → Performance tab
- •Start recording, reproduce the issue, stop recording
- •Look for:
- •Long tasks (>50ms) in the main thread
- •Excessive layout/reflow (purple bars)
- •Excessive paint (green bars)
- •High JS heap growth over time (memory leak)
Saving a trace for analysis:
- •Record in DevTools Performance tab
- •Right-click the timeline → "Save profile..."
- •Run
node scripts/analyze-trace.mjs <saved-trace.json>
Vitest Profiling
# Run with Node profiling node --prof node_modules/.bin/vitest run libs/grid/src/lib/core/internal/rows.spec.ts
Step 3: Investigate Hot Paths
The grid has known hot paths that must be kept fast:
Scroll Handler
- •Location:
libs/grid/src/lib/core/grid.ts(#handleScroll) - •Budget: < 1ms per scroll event
- •Rules:
- •No allocations in scroll handler (reuse pooled event object)
- •No DOM queries — cache element references
- •No
requestAnimationFramecalls — use scheduler - •Minimize function calls
Cell Rendering
- •Location:
libs/grid/src/lib/core/internal/rows.ts - •Rules:
- •Reuse row elements via row pool (
rowPool: HTMLElement[]) - •Minimize
createElementcalls - •Use
textContentoverinnerHTMLwhen possible - •Avoid
classList.add/removein loops — batch class changes
- •Reuse row elements via row pool (
Virtualization
- •Location:
libs/grid/src/lib/core/internal/rows.ts(refreshVirtualWindow) - •Rules:
- •Only render rows in the visible viewport + overscan
- •Default overscan: 8 rows
- •Use
transform: translateY()for row positioning
Render Scheduler
- •Location:
libs/grid/src/lib/core/internal/render-scheduler.ts - •All rendering goes through a single RenderScheduler — single RAF per frame
- •Rules:
- •Single RAF per frame (batched)
- •Highest phase wins (merges multiple requests)
- •Use
this.#scheduler.requestPhase(RenderPhase.X, 'source')to request renders - •Never call
requestAnimationFramedirectly for rendering (exception: scroll hot path)
Render Phases (deterministic execution order):
| Phase | Value | Work Performed |
|---|---|---|
STYLE | 1 | Plugin afterRender() hooks only |
VIRTUALIZATION | 2 | Recalculate virtual window |
HEADER | 3 | Re-render header row |
ROWS | 4 | Rebuild row model |
COLUMNS | 5 | Process columns, update CSS template |
FULL | 6 | Merge effective config + all lower phases |
Pipeline order: mergeConfig → processRows → processColumns → renderHeader → virtualWindow → afterRender
Step 4: Common Fixes
Reduce Allocations
// ❌ Bad — creates new object every scroll
onScroll() {
const event = { scrollTop: el.scrollTop, scrollLeft: el.scrollLeft };
this.notify(event);
}
// ✅ Good — reuse pooled object
#pooledEvent = { scrollTop: 0, scrollLeft: 0 };
onScroll() {
this.#pooledEvent.scrollTop = el.scrollTop;
this.#pooledEvent.scrollLeft = el.scrollLeft;
this.notify(this.#pooledEvent);
}
Batch DOM Operations
// ❌ Bad — causes layout thrashing
cells.forEach((cell) => {
const width = cell.offsetWidth; // READ (forces layout)
cell.style.width = width + 'px'; // WRITE (invalidates layout)
});
// ✅ Good — batch reads then writes
const widths = cells.map((cell) => cell.offsetWidth); // All READS
cells.forEach((cell, i) => {
cell.style.width = widths[i] + 'px'; // All WRITES
});
Use the Scheduler
// ❌ Bad — direct RAF call requestAnimationFrame(() => this.render()); // ✅ Good — use scheduler (batches with other requests) this.#scheduler.requestPhase(RenderPhase.ROWS, 'myFeature');
Lazy Initialization
// ❌ Bad — compute upfront even if not needed
class MyPlugin {
#expensiveData = computeExpensiveData();
}
// ✅ Good — compute on first access
class MyPlugin {
#expensiveData?: Data;
get expensiveData() {
return (this.#expensiveData ??= computeExpensiveData());
}
}
Step 5: Benchmark
After fixing, verify the improvement:
- •Playwright trace comparison — Capture traces before/after the fix and compare with
node scripts/analyze-trace.mjs - •Run e2e performance tests —
bun nx e2e e2e --grep="Performance"(enforces budgets, catches regressions) - •Run unit tests to verify no regressions —
bun nx test grid - •Check bundle size hasn't increased —
bun nx build grid(core ≤ 170 kB, gzip ≤ 45 kB) - •Add a performance test if the fix addresses a new hot path not yet covered by
e2e/tests/performance-regression.spec.ts
Performance Budget Summary
| Metric | Target |
|---|---|
| Scroll handler | < 1ms per event |
| Initial render (1000 rows) | < 50ms |
| Cell render (single) | < 0.1ms |
| Bundle size (core) | ≤ 170 kB (≤ 45 kB gzip) |
| Row virtualization overscan | 8 rows default |
Key Files to Investigate
| Area | File |
|---|---|
| Scroll handling | libs/grid/src/lib/core/grid.ts |
| Row rendering | libs/grid/src/lib/core/internal/rows.ts |
| Virtualization | libs/grid/src/lib/core/internal/rows.ts |
| Render scheduling | libs/grid/src/lib/core/internal/render-scheduler.ts |
| Column processing | libs/grid/src/lib/core/internal/columns.ts |
| Sticky columns | libs/grid/src/lib/core/internal/sticky.ts |
| Resize observer | libs/grid/src/lib/core/internal/resize.ts |