name: debug-perf
description: Investigate, optimize, and prevent performance regressions in @toolbox-web/grid. Use when something is slow, janky, or memory-leaking, when checking or guarding against a performance regression, when optimizing or improving a hot path, or when capturing/analyzing a performance trace. Covers Chrome DevTools MCP trace capture, the bundled trace analyzer, profiling, hot-path analysis, virtualization tuning, and render-scheduler optimization. (For micro-benchmark regression gating with .bench.ts, see the bench skill.)
argument-hint:
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
Need general browser debugging? (DOM inspection, console, screenshots, script evaluation) See the
debug-browserskill for the full Chrome DevTools MCP workflow.
Live Browser Profiling via Chrome DevTools MCP
The Chrome DevTools MCP server (pre-configured in .vscode/mcp.json) provides performance tracing tools that run against a live browser:
Start a dev server (see
debug-browserskill for server table):bun nx serve docs # Docs site on port 4400 bun nx serve demo-angular # Angular demo on port 4200Navigate to the page with the performance issue before starting the trace:
navigate_page → url: http://localhost:4200/Start a performance trace. Pick the mode based on what you're measuring:
Page-load trace (LCP/CLS/TTFB on a fresh load) — use the defaults, which reload the page and auto-stop once it settles:
performance_start_trace → reload: true, autoStop: trueInteraction trace (scroll/sort/filter/edit on an already-loaded grid — the common grid case) — you MUST disable reload and auto-stop, otherwise the trace reloads the page and stops before you can reproduce the interaction:
performance_start_trace → reload: false, autoStop: false
Reproduce the issue — use
click,press_key,fill,evaluate_scriptto interact with the page. (Only relevant for interaction traces; anautoStoppage-load trace has already finished by now.)Stop the trace, save the raw data, and analyze:
performance_stop_trace → filePath: <abs-path>/trace.json performance_analyze_insight → insightSetId: <id>, insightName: <e.g. LCPBreakdown>performance_stop_tracereturns a structured summary (LCP, CLS, TTFB, render delay, forced reflows, DOM size, render-blocking resources) and a list of available insight sets/ids.performance_start_tracealso acceptsfilePathif you want the page-load trace saved at start.performance_analyze_insightdrills into one highlighted insight (e.g.DocumentLatency,LCPBreakdown,ForcedReflow) using aninsightSetIdfrom the stop-trace output.Because
filePathwrites the raw trace JSON, you can feed it straight into this skill's bundled analyzer for grid-specific long-task / layout-thrash / scroll breakdown (see "Playwright Trace Capture" below — same analyzer, same file format):node .github/skills/debug-perf/analyze-trace.mjs trace.json
This is the full start → act → stop → download → analyze loop: capture with the MCP, read the high-level insights inline, then run
analyze-trace.mjson the saved file for the detailed hot-path report.Targeted measurement via script evaluation — for measuring specific code paths:
// Measure the cost of rows replacement () => { const grid = document.querySelector('tbw-grid'); const start = performance.now(); grid.rows = [...grid.rows]; // Trigger re-render return new Promise((resolve) => { requestAnimationFrame(() => { requestAnimationFrame(() => { resolve({ durationMs: performance.now() - start }); }); }); }); };Monkey-patch hot paths to count executions or measure timings:
// Track how often renderVisibleRows is called and how long it takes () => { const grid = document.querySelector('tbw-grid'); const original = grid.refreshVirtualWindow.bind(grid); window.__renderLogs = []; grid.refreshVirtualWindow = (force, skip) => { const start = performance.now(); const result = original(force, skip); window.__renderLogs.push({ force, duration: performance.now() - start, stack: new Error().stack?.split('\n').slice(1, 3).join('\n'), }); return result; }; return { patched: true }; };
This approach is ideal for investigating real-world scenarios that are hard to reproduce in test environments — such as framework adapter interaction, Angular change detection overhead, or large dataset rendering.
Playwright Trace Capture (CI-Friendly)
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:4200'); // demo app
// ... reproduce the performance issue ...
await page.tracing.stop({ path: 'trace.json' });
await browser.close();
Then analyze with this skill's bundled trace analyzer (zero dependencies, runs under Node or Bun):
# Extract long tasks, layout thrashing, forced reflows, scroll bottlenecks
node .github/skills/debug-perf/analyze-trace.mjs trace.json
The analyze-trace.mjs script (co-located in this skill folder) 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 .github/skills/debug-perf/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
Tip: For complex investigations that span multiple tools (e.g., profiling in Chrome MCP → identifying code path → writing a targeted unit test → verifying fix in browser), combine the
debug-perfanddebug-browserskills.
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 at a glance
| Rule | Why it matters | Example |
|---|---|---|
| Single RAF per frame (batched) | Prevents multiple layout/paint passes per frame. | Scheduler coalesces all requests into one RAF callback. |
| Highest phase wins | A FULL request supersedes a STYLE request in the same frame, so work is never duplicated. |
If STYLE and ROWS are requested, the scheduler runs from ROWS downward. |
| Always go through the scheduler | Guarantees deterministic phase order and merging. | this.#scheduler.requestPhase(RenderPhase.ROWS, 'source'). |
Never call requestAnimationFrame directly for rendering |
Bypasses batching and breaks phase ordering. | Exception: the scroll hot path, which is documented in render-scheduler.ts. |
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 .github/skills/debug-perf/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 raw, ≤ 50 kB gzip with warning at 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 raw, ≤ 50 kB gzip (warn at 45 kB) |
| 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 |