Step 5 — The DevTools Hunt

Goal

Find four seeded leaks in an unfamiliar application using only the Memory panel, and write a retainer path for each. This is the step that transfers to real work.

Prerequisites

  • Steps 1–4 complete

Setup

cd src
npm run serve      # http://localhost:8081/leaky-app.html

An SPA-shaped app with three routes. web/leaky-app.html contains four distinct leaks. Do not read the source until you have found them from evidence — reading it spends the exercise, and the exercise is the deliverable.


The workflow, in the order that works

  1. Establish the repeated action. Navigating between routes. It is idempotent: after one navigation the app is in the same logical state.
  2. Warm up. Click Navigate ×10 once and discard it. Caches and lazy compilation are not leaks, and skipping this is how false positives are born.
  3. Snapshot 1. DevTools → Memory → Heap snapshot. It forces a GC first, so you do not need to.
  4. Click Navigate ×50.
  5. Snapshot 2.
  6. Comparison view, sorted by Delta. Look for classes whose count grew by exactly 50 or a multiple of it. That proportionality is the signal — noise does not arrive in multiples of your loop count.
  7. Select an instance → Retainers panel. Walk the path to a root. This is the finding.
  8. Filter the class list by Detached to isolate detached DOM directly.

Deliverable

For each of the four leaks, write down:

#Class that grewRetainer path from a GC rootEdge to breakLocal or architectural?
1
2
3
4

"It's a closure" is not a retainer path. A retainer path names the root and every hop: window → appState.cache (Map) → {…} → HTMLDivElement.

Hints, in increasing order of spoiler

Hint 1 — how many kinds am I looking for?

Four leaks, four different shapes from CONCEPTS.md §3. If you have found two of the same shape, you are missing two others.

Hint 2 — one is invisible in the JS heap

Filter by Detached. Step 2 told you why the JS-heap number will under-report it.

Hint 3 — two involve registrations on long-lived targets

window is a GC root. So is the timer registry. Both hold closures. destroy() addresses neither — and it looks complete, which is the point.

Hint 4 — two are collections that only grow

Look at the #stats line in the app. It is telling you the size of both of them on every navigation, in plain text. Most people do not read it.

Then fix them

Fix each leak, re-run the comparison, and confirm the deltas are gone. For each fix, state whether it is local (one line moved) or architectural (someone now owns a policy). Two of these four are architectural — being able to say which is the Principal-level part of the exercise.

Debugging exercise

Run the app under the specification's incident protocol as though it were a production report of "the dashboard gets slow after a few hours":

  1. What do you investigate first?
  2. What evidence do you need?
  3. What hypotheses exist? — include at least one that is not a leak
  4. What experiments distinguish them?
  5. What mitigation is appropriate? — before the fix ships
  6. What permanent fix is appropriate?
  7. What systemic change prevents recurrence?

Question 3 is the one that separates levels. "GC pauses from a leak" and "a genuine long task" present identically to a user, and the discriminator is cheap: a leak correlates with session age, a scheduling problem reproduces on a fresh page load. If that check is not in the triage runbook, nobody performs it — which makes question 7's answer a document, not a code change.

Checkpoint

docs/verification.md Checkpoint 6.