Evidence-based investigation across every leak vector (timers, listeners, object
URLs, observers, websockets, static server collections, IMemoryCache, Python
caches). Verdict: no confirmed memory leak — the codebase has disciplined
cleanup. One resource-release correctness bug (over-eager blob-URL revocation in
the CV carousel) was found and fixed (eed9b1f).
Adds docs/performance/: MEMORY_LEAK_REPORT.md, ROOT_CAUSE_ANALYSIS.md,
PERFORMANCE_IMPROVEMENTS.md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
4.0 KiB
Performance Improvements — Job Tracker
Companion to: MEMORY_LEAK_REPORT.md · ROOT_CAUSE_ANALYSIS.md
Changes made (this pass)
| Change | File | Effect | Verified |
|---|---|---|---|
| Revoke CV-preview blob URLs on unmount only (ref-based), not on every carousel change | job-tracker-ui/src/pages/ProfilePage.tsx |
Fixes broken previews on multi-template decks; still frees URLs on unmount | profile-page.test.tsx 5/5 |
Context: this was the only defect found in a full resource audit. The codebase already practises disciplined cleanup (timers cleared, listeners removed, object URLs revoked), so there was no leak to fix — see the main report.
Recommended (low-severity, optional)
1. Stabilise the extraction-run poll — minor
ProfilePage.tsx:315-324 recreates its 4s interval on every poll because extractionRuns is in the deps
and changes each tick. It's harmless (cleanup runs; it stops when runs finish) but churns. If touched:
poll on a stable trigger (e.g. a boolean hasActiveRuns in deps, or read runs from a ref inside the
interval) so the interval is created once per active-window.
2. One live heap-snapshot pass on a populated session — verification, not a fix
The static audit is strong, but a single DevTools confirmation closes the loop:
- Run the real stack (backend on
:5202+ a seeded DB) and sign in. - DevTools → Memory → take a heap snapshot.
- Navigate
/dashboard → /jobs → open a job dialog → close → /profile → build a CV deck → back, ×5. - Force GC, take a second snapshot, Comparison view.
- Expect: node/listener/detached counts return to baseline (sawtooth), not monotonic growth. Sort
retained size by constructor; look for
Detached HTMLElement, growingArray/Map, or listener counts that never fall.
Also cheap and useful: performance.memory.usedJSHeapSize (Chromium) logged across the loop, or a
Playwright script that repeats the navigation and asserts heap stays bounded.
3. Guard async setState after unmount — defensive, not a current leak
Several components await api…().then(setState). React 18 no-ops setState on unmounted components (just a
dev warning historically), so this is not a leak, but for long CV/AI calls consider an AbortController
on the request (cancels the in-flight network work on unmount) — improves responsiveness and avoids wasted
work more than memory.
Prevention — keep leaks from creeping in
- Lint: enable
react-hooks/exhaustive-deps(surfaces the exact wrong-deps class that caused the one bug here) and considerreact-hooks/react-compilerchecks. - Rule of thumb: any effect that acquires a resource (listener, timer, object URL, observer, subscription, connection) must return a cleanup that releases exactly that resource. "Release once on unmount" ⇒ empty-deps effect + a ref for current state — never a value in the deps array.
- Object URLs: pair every
createObjectURLwith arevokeObjectURLin the same owner; prefer revoking on unmount/replace, never on unrelated re-renders. - Server caches: every
IMemoryCache.Setmust carry an absolute/sliding expiration (asGmailOAuthServicecorrectly does); if the app grows to heavy caching, set aSizeLimit. - No unbounded static state: keep
staticcollections to fixed lookup tables (as today); never accumulate per-request data in a static field. - CI: the heavy RTL suites are timeout-flaky under load — raising
testTimeout(e.g. 15–20s) or reducing jest worker contention would make regressions (including any future leak-guard tests) reliably visible instead of hidden behind flakes.
Security-audit note (standing instruction)
The applied change carries no security surface (client-side URL lifetime only). The recommendations above introduce none either; if #3 (AbortController) is implemented, ensure aborted requests don't leave partial writes — not applicable to the read-only CV export/preview calls here.