debug-perf

star 28

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.)

OysteinAmundsen By OysteinAmundsen schedule Updated 6/3/2026

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-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:

  1. Start a dev server (see debug-browser skill for server table):

    bun nx serve docs     # Docs site on port 4400
    bun nx serve demo-angular   # Angular demo on port 4200
    
  2. Navigate to the page with the performance issue before starting the trace:

    navigate_page → url: http://localhost:4200/
    
  3. 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
      
  4. 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.)

  5. 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.

  6. 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 });
          });
        });
      });
    };
    
  7. 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)

  1. Open Chrome DevTools → Performance tab
  2. Start recording, reproduce the issue, stop recording
  3. 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:

  1. Record in DevTools Performance tab
  2. Right-click the timeline → "Save profile..."
  3. 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-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

// ❌ 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:

  1. Playwright trace comparison — Capture traces before/after the fix and compare with node .github/skills/debug-perf/analyze-trace.mjs
  2. Run e2e performance testsbun nx e2e e2e --grep="Performance" (enforces budgets, catches regressions)
  3. Run unit tests to verify no regressions — bun nx test grid
  4. Check bundle size hasn't increased — bun nx build grid (core ≤ 170 kB raw, ≤ 50 kB gzip with warning at 45 kB)
  5. 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
Install via CLI
npx skills add https://github.com/OysteinAmundsen/toolbox --skill debug-perf
Repository Details
star Stars 28
call_split Forks 2
navigation Branch main
article Path SKILL.md
More from Creator
OysteinAmundsen
OysteinAmundsen Explore all skills →