| 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 | <symptom-description> |
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-browser skill 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-browser skill for server table):
bun nx serve docs
bun nx serve demo-angular
-
Navigate 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: true
-
Interaction 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_script to interact
with the page. (Only relevant for interaction traces; an autoStop page-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_trace returns 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_trace also accepts filePath if you want the page-load
trace saved at start.
-
performance_analyze_insight drills into one highlighted insight (e.g. DocumentLatency,
LCPBreakdown, ForcedReflow) using an insightSetId from the stop-trace output.
-
Because filePath writes 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.mjs on the saved file for the
detailed hot-path report.
-
Targeted measurement via script evaluation — for measuring specific code paths:
() => {
const grid = document.querySelector('tbw-grid');
const start = performance.now();
grid.rows = [...grid.rows];
return new Promise((resolve) => {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
resolve({ durationMs: performance.now() - start });
});
});
});
};
-
Monkey-patch hot paths to count executions or measure timings:
() => {
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:
import { chromium } from '@playwright/test';
const browser = await chromium.launch();
const page = await browser.newPage();
await page.tracing.start({ screenshots: true, categories: ['devtools.timeline'] });
await page.goto('http://localhost:4200');
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):
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:
bun nx e2e e2e --grep="Performance"
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);
});
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
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-perf and debug-browser skills.
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
requestAnimationFrame calls — 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
createElement calls
- Use
textContent over innerHTML when possible
- Avoid
classList.add/remove in loops — batch class changes
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
onScroll() {
const event = { scrollTop: el.scrollTop, scrollLeft: el.scrollLeft };
this.notify(event);
}
#pooledEvent = { scrollTop: 0, scrollLeft: 0 };
onScroll() {
this.#pooledEvent.scrollTop = el.scrollTop;
this.#pooledEvent.scrollLeft = el.scrollLeft;
this.notify(this.#pooledEvent);
}
Batch DOM Operations
cells.forEach((cell) => {
const width = cell.offsetWidth;
cell.style.width = width + 'px';
});
const widths = cells.map((cell) => cell.offsetWidth);
cells.forEach((cell, i) => {
cell.style.width = widths[i] + 'px';
});
Use the Scheduler
requestAnimationFrame(() => this.render());
this.#scheduler.requestPhase(RenderPhase.ROWS, 'myFeature');
Lazy Initialization
class MyPlugin {
#expensiveData = computeExpensiveData();
}
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 |