Skip to content

⚡️ Fix TDD SSE performance by splitting heavy comparison data#214

Merged
Robdel12 merged 2 commits intomainfrom
fix/tdd-sse-performance
Feb 11, 2026
Merged

⚡️ Fix TDD SSE performance by splitting heavy comparison data#214
Robdel12 merged 2 commits intomainfrom
fix/tdd-sse-performance

Conversation

@Robdel12
Copy link
Contributor

Summary

Fixes #213 — SSE broadcasts in TDD mode were sending the entire report-data.json (735 KB per update, ~97 MB cumulative for 135 comparisons). Root cause: tdd-handler.js spread the full comparison object (including diffClusters, intensityStats, etc.) into the file, and the groups array duplicated every comparison.

  • Split storage into two files: report-data.json (lightweight, broadcast via SSE) and comparison-details.json (heavy fields, persisted but never broadcast)
  • Remove groups array from report-data.json — UI already computes groups client-side via groupComparisons()
  • Switch to compact JSON — no more pretty-printing machine-read files
  • Add GET /api/comparison/:id endpoint that merges lightweight + heavy data on-demand (supports lookup by id, signature, or name)
  • Add useComparison hook — frontend fetches full comparison details only when the fullscreen viewer opens
  • Skip empty detail entries — passed comparisons with no heavy data don't write to comparison-details.json
  • Add boolean hints (hasDiffClusters, hasConfirmedRegions) so UI can show toggle buttons before heavy data loads

Results (135 comparisons, 68 with diffs)

Metric Before After Reduction
Per SSE update 735 KB 153 KB 79%
Cumulative broadcast ~97 MB ~20 MB 79%
report-data.json on disk 735 KB 153 KB 79%

Files changed

  • src/server/handlers/tdd-handler.js — Split writes, remove groups, compact JSON
  • src/server/routers/dashboard.js — New /api/comparison/:id endpoint
  • src/reporter/src/api/client.jsgetComparison(id) method
  • src/reporter/src/lib/query-keys.js — Comparison query key
  • src/reporter/src/hooks/queries/use-tdd-queries.jsuseComparison hook
  • src/reporter/src/components/views/comparison-detail-view.jsx — On-demand fetch + merge
  • src/reporter/src/components/comparison/fullscreen-viewer.jsx — Boolean hint fallbacks for regions toggle

Test plan

  • npm test — all 1882 tests pass
  • New tests for /api/comparison/:id (merged data, lookup by signature/name, 404s, no details file)
  • New test verifying heavy fields excluded from report-data.json and present in comparison-details.json
  • E2E: vizzly tdd run in vizzly project — verified file sizes and data integrity across two runs
  • Verify fullscreen viewer still shows diffClusters and confirmedRegions via on-demand fetch
  • Verify SSE payload in browser DevTools Network tab (EventStream)

Split report-data.json into lightweight SSE data and on-demand heavy fields:

- report-data.json: Only lightweight comparison fields (broadcast via SSE)
- comparison-details.json: Heavy fields like diffClusters, intensityStats,
  boundingBox, regionAnalysis, hotspotAnalysis, confirmedRegions (persisted,
  never broadcast)

Remove redundant groups array from report-data.json — UI already computes
groups client-side via groupComparisons(). Switch to compact JSON serialization.

Add GET /api/comparison/:id endpoint that merges lightweight + heavy data
on-demand. Frontend fetches full details only when viewer opens via new
useComparison hook.

Result: 79% reduction in SSE payload (735 KB → 153 KB per update),
cumulative broadcast drops from ~97 MB to ~20 MB for 135 comparisons.
@vizzly-testing

This comment has been minimized.

@claude
Copy link

claude bot commented Feb 11, 2026

Code Review - PR #214

Summary

This PR addresses a significant performance issue in TDD mode where SSE broadcasts were sending 735KB per update. The solution splits comparison data into lightweight (SSE-broadcast) and heavy (on-demand) files, achieving a 79% reduction in payload size. The approach is well-architected and the implementation is solid.


✅ Strengths

Architecture & Design

  • Clean separation of concerns: Lightweight data in report-data.json (SSE-broadcast) vs heavy fields in comparison-details.json (on-demand fetch)
  • Smart boolean hints: hasDiffClusters and hasConfirmedRegions allow UI to show toggle buttons before heavy data loads
  • Graceful degradation: Frontend works with lightweight data until fullscreen viewer needs heavy fields
  • On-demand loading: New useComparison hook and /api/comparison/:id endpoint fetch details only when needed

Implementation Quality

  • Comprehensive test coverage: New tests cover all lookup methods (by id/signature/name), 404 cases, and data split verification
  • Backward compatibility: API endpoint gracefully handles missing details file
  • Multiple lookup methods: Supports id, signature, and name lookups for flexibility
  • Skip empty writes: Passed comparisons without heavy data don't bloat comparison-details.json

Performance Impact

  • Per SSE update: 735 KB → 153 KB (79% reduction)
  • Cumulative broadcast: ~97 MB → ~20 MB (79% reduction)
  • This is a significant win for developer experience in TDD mode

⚠️ Issues Found

🔴 Critical: Race Condition in File Updates

Location: src/server/handlers/tdd-handler.js:221-230 and :235-239

The updateComparisonDetails and removeComparisonDetails functions have a read-modify-write race condition:

const updateComparisonDetails = (id, heavyFields) => {
  let details = readComparisonDetails();  // Read
  details[id] = heavyFields;              // Modify
  writeFileSync(detailsPath, JSON.stringify(details));  // Write
};

Problem: In parallel test execution (which Vizzly supports), multiple screenshots could arrive simultaneously. Without atomic updates or file locking, concurrent writes to comparison-details.json can cause:

  • Lost updates (later write overwrites earlier write)
  • Corrupted JSON (simultaneous writes)
  • Missing comparison details

Impact: This is especially problematic because:

  1. The PR description mentions parallel builds as a key feature
  2. Tests can run in parallel shards
  3. Race conditions are hard to reproduce and debug

Recommendation: Use a file locking mechanism or queue writes. Options:

  • Use proper-lockfile npm package for atomic file operations
  • Queue writes with a single writer pattern
  • Use atomically package for atomic file writes
  • Consider a simple in-memory queue that serializes writes

🟡 Medium: Missing Error Handling for Corrupted Details File

Location: src/server/routers/dashboard.js:104-113

try {
  let details = JSON.parse(readFileSync(detailsPath, 'utf8'));
  let heavy = details[comparison.id];
  if (heavy) {
    comparison = { ...comparison, ...heavy };
  }
} catch {
  // Details file is optional, continue without heavy fields
}

Problem: The catch block silently swallows all errors, including JSON parse errors from corrupted files (which could happen due to the race condition above).

Recommendation: Log errors at debug level at minimum:

} catch (error) {
  output.debug('Failed to read comparison details:', { error: error.message });
  // Continue without heavy fields
}

This helps with debugging if the race condition manifests.

🟡 Medium: Potential Memory Issues with Large Details File

Location: src/server/handlers/tdd-handler.js:207-214

const readComparisonDetails = () => {
  try {
    if (!existsSync(detailsPath)) return {};
    return JSON.parse(readFileSync(detailsPath, 'utf8'));
  } catch {
    return {};
  }
};

Problem: Every write to comparison-details.json reads the entire file into memory. For projects with hundreds of comparisons with diffs, this file could grow large (each comparison with heavy fields could be 10-50KB).

Impact:

  • 200 comparisons × 20KB average = 4MB file
  • Each screenshot triggers a read (4MB) + write (4MB)
  • Could slow down TDD mode for large projects

Recommendation: Consider one of:

  1. Keep current approach but document the limitation
  2. Use separate files per comparison (.vizzly/details/<comparison-id>.json)
  3. Use a lightweight embedded database like SQLite
  4. Add periodic cleanup of old comparison details

For now, I'd suggest documenting this as a known limitation and considering optimization if users report issues.

🟢 Minor: Inconsistent Error Handling

Location: src/server/handlers/tdd-handler.js:207-214

The readComparisonDetails function silently catches all errors, while readReportData logs errors. This inconsistency makes debugging harder.

Recommendation: Add error logging to readComparisonDetails:

const readComparisonDetails = () => {
  try {
    if (!existsSync(detailsPath)) return {};
    return JSON.parse(readFileSync(detailsPath, 'utf8'));
  } catch (error) {
    output.debug('Failed to read comparison details:', error);
    return {};
  }
};

🟢 Minor: Missing Validation in API Endpoint

Location: src/server/routers/dashboard.js:76

let comparisonId = decodeURIComponent(comparisonMatch[1]);

Problem: No validation that comparisonId is a non-empty string after decoding. Edge case: /api/comparison/ (empty id) would match the regex.

Recommendation: Add validation:

let comparisonId = decodeURIComponent(comparisonMatch[1]);
if (!comparisonId) {
  sendError(res, 400, 'Comparison ID is required');
  return true;
}

💡 Suggestions

Performance: Consider Streaming for Large Responses

The /api/comparison/:id endpoint returns the full comparison in one response. For comparisons with huge diffClusters arrays, this could be slow. Consider:

  • Paginating diffClusters if they exceed a certain size
  • Compressing the response (gzip)

However, this might be premature optimization. Monitor if it becomes an issue.

UX: Loading States

The comparison-detail-view.jsx merges lightweight and heavy data, but doesn't show a loading state while fetching heavy fields. Users might not know the fullscreen viewer is loading data. Consider:

  • Show a subtle loading indicator for diff clusters/regions
  • The boolean hints (hasDiffClusters) could drive skeleton loaders

Testing: Add E2E Test for Race Condition

The current tests don't cover concurrent screenshot arrivals. Consider adding a test that:

  1. Sends multiple screenshots simultaneously
  2. Verifies all heavy fields are present in comparison-details.json
  3. Verifies no data corruption

This would catch the race condition issue.

Code: Extract Magic Strings

The heavy field names are duplicated across files:

  • src/server/handlers/tdd-handler.js:532-538 (write)
  • src/reporter/src/components/views/comparison-detail-view.jsx:54-59 (read/merge)

Consider extracting to a shared constant:

export const HEAVY_COMPARISON_FIELDS = [
  'diffClusters',
  'intensityStats',
  'boundingBox',
  'regionAnalysis',
  'hotspotAnalysis',
  'confirmedRegions',
];

🧪 Test Plan Items

The test plan shows two items as incomplete:

  • Verify fullscreen viewer still shows diffClusters and confirmedRegions via on-demand fetch
  • Verify SSE payload in browser DevTools Network tab (EventStream)

Recommendation: Complete these before merging. The E2E verification is crucial to ensure the UX works as expected.


🔒 Security

No security concerns identified. The comparison ID is properly URL-encoded/decoded, and file paths use safe joins.


📊 Test Coverage

Excellent test coverage! New tests cover:

  • ✅ Data split verification (heavy fields excluded from report-data.json)
  • ✅ API endpoint with all lookup methods
  • ✅ 404 cases
  • ✅ Missing details file handling
  • ✅ Boolean hints

Verdict

This is a high-quality PR that solves a real performance problem with a clean architectural approach. The race condition issue is critical and must be fixed before merging, but the fix is straightforward (add file locking or write queueing).

Required Before Merge:

  1. Fix race condition in updateComparisonDetails and removeComparisonDetails
  2. Complete E2E testing (fullscreen viewer and SSE payload verification)
  3. Add error logging to readComparisonDetails for debugging

Recommended Before Merge:

  • Add validation for empty comparison ID in API endpoint
  • Add error logging in dashboard router's details file catch block

Consider for Future:

  • Monitor memory usage with large projects (hundreds of comparisons)
  • Add loading states in fullscreen viewer
  • Extract heavy field names to shared constants
  • Add concurrent write test

Great work on identifying and solving this performance bottleneck! The 79% reduction in SSE payload is impressive. 🚀

- Add debug logging to readComparisonDetails for consistency
- Add empty comparison ID validation in /api/comparison/:id
- Add debug logging for details file parse errors in dashboard router
@vizzly-testing
Copy link

vizzly-testing bot commented Feb 11, 2026

Vizzly - Visual Test Results

CLI Reporter - 7 changes need review
Status Count
Passed 10
Changed 7
Auto-approved 12
Changes needing review (7)

viewer-slide-mode · Firefox · 1920×1080 · 0.5% diff

viewer-slide-mode

filter-failed-only · Firefox · 375×703 · 0.2% diff

filter-failed-only

fullscreen-viewer · Firefox · 1920×1080 · 0.1% diff

fullscreen-viewer

bulk-accept-dialog · Firefox · 1920×1080 · 10.3% diff

bulk-accept-dialog

bulk-accept-dialog · Firefox · 375×667 · 95.2% diff

bulk-accept-dialog

fullscreen-viewer · Firefox · 375×667 · 97.5% diff

fullscreen-viewer

...and 1 more in Vizzly.

Review changes

CLI TUI - Approved

5 comparisons, no changes detected.

View build


fix/tdd-sse-performance · e29db8bf

@Robdel12 Robdel12 merged commit 6e595bf into main Feb 11, 2026
27 of 28 checks passed
@Robdel12 Robdel12 deleted the fix/tdd-sse-performance branch February 11, 2026 18:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

TDD: extremely poor performance with lots of diffs

1 participant