跪拜 Guibai
← Back to the summary

The Chrome DevTools Workflow That Catches Every Frontend Memory Leak

1. What is a memory leak

A memory leak is not simply "high memory usage"; it is:

After a certain operation ends, objects that should have been released are still referenced.
Repeating the operation causes JS heap, DOM nodes, and event listeners to continuously increase.
Even after manually triggering GC, they cannot return to near their initial values.

Typical symptoms:


2. Common memory leak examples and fixes

2.1 Timers not cleaned up

Problematic code:

function mount() {
  setInterval(() => {
    console.log('polling')
  }, 1000)
}

After the component unmounts, the timer is still running, and variables in the closure are not released.

Fix:

let timer = null

function mount() {
  timer = setInterval(() => {
    console.log('polling')
  }, 1000)
}

function unmount() {
  clearInterval(timer)
  timer = null
}

React approach:

useEffect(() => {
  const timer = setInterval(fetchData, 1000)

  return () => {
    clearInterval(timer)
  }
}, [])

2.2 Event listeners not removed

Problematic code:

function mount() {
  window.addEventListener('resize', () => {
    console.log('resize')
  })
}

Anonymous functions have no reference, making subsequent removeEventListener impossible.

Fix:

function onResize() {
  console.log('resize')
}

function mount() {
  window.addEventListener('resize', onResize)
}

function unmount() {
  window.removeEventListener('resize', onResize)
}

React approach:

useEffect(() => {
  function onResize() {
    console.log(window.innerWidth)
  }

  window.addEventListener('resize', onResize)

  return () => {
    window.removeEventListener('resize', onResize)
  }
}, [])

2.3 DOM removed but JS still holds a reference

Problematic code:

let cachedNode = null

function createAndRemove() {
  const div = document.createElement('div')
  div.innerHTML = '<span>hello</span>'

  document.body.appendChild(div)
  document.body.removeChild(div)

  cachedNode = div
}

div is no longer in the page's DOM tree, but cachedNode still references it, so it cannot be GC'd.

Fix:

cachedNode = null

Common sources in real projects:


2.4 Observer not released

Problematic code:

const observer = new ResizeObserver(() => {
  console.log('resize')
})

function mount(el) {
  observer.observe(el)
}

Fix:

let observer = null

function mount(el) {
  observer = new ResizeObserver(() => {
    console.log('resize')
  })

  observer.observe(el)
}

function unmount() {
  observer.disconnect()
  observer = null
}

Similar objects requiring cleanup:

MutationObserver.disconnect()
ResizeObserver.disconnect()
IntersectionObserver.disconnect()
WebSocket.close()
AbortController.abort()
subscription.unsubscribe()
chart.destroy()

3. Chrome investigation workflow

When investigating, don't jump straight to Heap Snapshot. The recommended order is:

1. Use Performance monitor to see real-time trends
2. Use the Performance panel to record a complete operation path
3. Use the Memory panel to take Heap Snapshots for comparison
4. Search for Detached DOM or business component names
5. Check the Retainers reference chain
6. Fix the code
7. Re-test using the same path

Key metrics for judging a leak:

JS heap size
DOM Nodes
JS event listeners
Documents
Frames

4. Chrome Task Manager for a quick memory check

Chrome Task Manager is the lightest entry point, suitable for initially judging whether a tab has abnormal memory usage.

How to open

  1. Press Shift + Esc in Chrome
  2. Or click the Chrome menu in the top right
  3. Select More tools
  4. Select Task manager
  5. Right-click the header and check JavaScript memory

Screenshot

Chrome Task Manager JavaScript Memory

What to look at

Focus on two columns:

Memory footprint:
  Memory at the browser process level; DOM nodes also occupy memory here.

JavaScript Memory:
  JS heap usage.
  The live value in parentheses is more important, indicating memory occupied by currently reachable objects.

Usage example

Investigating "a background page becomes slower with use":

  1. Open the target page
  2. Open Task Manager
  3. Check JavaScript memory
  4. Record the initial value
  5. Repeat the operation: open modal -> close modal, 20 times
  6. See if the live value of JavaScript Memory continuously increases

If the live value rises from 50 MB to 120 MB and does not decrease after waiting, you need to proceed with the Memory panel to locate the issue.


5. Real-time monitoring with Performance Monitor

The Performance monitor is suitable for observing runtime metrics in real-time. It can quickly tell you "if there is a growth trend."

How to open

  1. Open DevTools
  2. Press Command + Shift + P
  3. On Windows / Linux, it's Control + Shift + P
  4. Type Performance monitor
  5. Select Show Performance monitor

Or:

DevTools top-right three dots
-> More tools
-> Performance monitor

Screenshot

Performance Monitor

Metric descriptions

CPU usage:
  CPU utilization. Persistently high indicates heavy computation, infinite loops, or frequent rendering.

JS heap size:
  JS heap memory. If it continuously rises after repeated operations, JS object leaks may exist.

DOM Nodes:
  Number of page DOM nodes. If it doesn't decrease after opening/closing a modal, it's often a Detached DOM leak.

JS event listeners:
  Number of event listeners. If it doesn't decrease after page switching or modal closing, listeners may not have been removed.

Documents:
  Number of documents. If it continuously rises after SPA route switching, be wary of iframe or document reference leaks.

Frames:
  Number of frames. Increases when iframes are created but not destroyed.

Layouts / sec:
  Layouts per second. Frequent increases may indicate layout thrashing.

Style recalcs / sec:
  Style recalculations per second. Frequent increases may indicate excessive style changes or DOM queries.

Usage example: Modal leak

Steps:

  1. Open Performance monitor
  2. Record initial values:
JS heap size: 45 MB
DOM Nodes: 3000
JS event listeners: 800
  1. Repeat the operation 20 times:
Open user detail modal
Close user detail modal
  1. Observe final values:
JS heap size: 95 MB
DOM Nodes: 9000
JS event listeners: 2200

Judgment:

DOM Nodes increase: Modal DOM or sub-components may not have been released.
JS event listeners increase: Event listeners inside the modal may not have been removed.
JS heap size increase: Component instances, data caches, or closures may still be referenced.

Next step: Go to Memory -> Heap snapshot to locate specific objects.


6. Recording memory curves with the Performance panel

The Performance panel is suitable for viewing memory changes during "one complete user flow."

How to open

  1. Open DevTools
  2. Go to the Performance panel
  3. Check Memory
  4. Click the record button
  5. Click the trash/broom button to manually trigger GC
  6. Perform business operations
  7. Manually trigger GC again
  8. Stop recording

Screenshot

Performance Memory Recording

What to look at

In the recording results, focus on:

JS Heap
Documents
Nodes
Listeners
GPU memory

Normal curve

Increases when the modal opens
Decreases after the modal closes
Returns to near initial value after manual GC

Abnormal curve

Increases every time the modal opens
Does not decrease after closing
Still much higher than the initial value after manual GC

Usage example: Route switching leak

Test path:

/user/list -> /user/detail -> /user/list
Repeat 10 times

Observation:

Initial DOM Nodes: 2500
DOM Nodes after 10 switches: 8000
DOM Nodes after manual GC: 7800

Conclusion:

After the detail page unmounts, DOM or component instances are not released.
Common causes are event listeners, global stores, cache Maps, or third-party component instances still referencing detail page objects.

7. Memory panel: Locating leaked objects with Heap Snapshot

Heap Snapshot is the most important tool for locating memory leaks.

It can answer two questions:

1. Which objects were not released?
2. Who is still referencing them?

How to open

  1. Open DevTools
  2. Go to the Memory panel
  3. Select Heap snapshot
  4. Click Take snapshot

Screenshot

Take Heap Snapshot

Recommended workflow

  1. Refresh the page to reach a stable state
  2. Manually trigger GC
  3. Take Snapshot 1
  4. Perform the suspicious operation, e.g., open and close a modal 20 times
  5. Manually trigger GC
  6. Take Snapshot 2
  7. Select Snapshot 2
  8. Switch the view to Comparison
  9. Compare with Snapshot 1

Key fields in Comparison

# New:
  Number of new objects

# Deleted:
  Number of deleted objects

Delta:
  Net growth count

Alloc. Size:
  Memory occupied by new objects

Freed Size:
  Memory occupied by freed objects

Size Delta:
  Net memory growth

Usage example

You find:

Detached HTMLDivElement +400
UserDetailModal +20
ResizeObserver +20
Array +1000

Explanation:

Detached HTMLDivElement:
  DOM has left the page but is still referenced by JS.

UserDetailModal:
  Modal component instances were not released.

ResizeObserver:
  Observer inside the modal was not disconnected.

8. Investigating Detached DOM

Detached DOM is one of the most common frontend memory leaks.

What is Detached DOM

A DOM node has been removed from the page's DOM tree,
but JavaScript still holds a reference to this node,
so the browser cannot reclaim it.

Screenshot

Filter Detached Nodes

Investigation steps

  1. Open Memory
  2. Select Heap snapshot
  3. Take the first snapshot
  4. Perform the suspicious operation
  5. Manually trigger GC
  6. Take the second snapshot
  7. In the Class filter, type:
Detached
  1. Check for:
Detached HTMLDivElement
Detached HTMLUListElement
Detached HTMLLIElement
Detached HTMLElement
  1. Click on an object
  2. Look at the Retainers panel below

How to read Retainers

Retainers shows "who is referencing this object."

Example:

Window
  -> modalService
    -> activeModals
      -> UserDetailModal
        -> rootElement
          -> Detached HTMLDivElement

Explanation:

Window has a modalService.
modalService.activeModals holds UserDetailModal.
UserDetailModal.rootElement references a DOM node that has already been removed.
Therefore, this DOM cannot be released.

Fix direction:

class ModalService {
  activeModals = new Set()

  open(modal) {
    this.activeModals.add(modal)
  }

  close(modal) {
    modal.destroy()
    this.activeModals.delete(modal)
  }
}

9. Memory panel: Allocations on timeline

Allocations on timeline is suitable for checking "which objects were created during a certain operation period, and which objects are still alive at the end."

How to open

  1. Open DevTools
  2. Go to Memory
  3. Select Allocations on timeline
  4. Click Start
  5. Perform business operations
  6. Click Stop

How to read

Blue bars:
  Allocated and ultimately still alive; primary suspects.

Gray bars:
  Allocated but already GC'd; usually not leaks.

Usage example

Operation:

Open modal -> Close modal
Repeat 10 times

Results show blue objects concentrated in:

ModalComponent
ChartInstance
ResizeObserver
HTMLDivElement

This indicates these objects are still alive after the operation ends; further Retainer analysis is needed.


10. Memory panel: Allocation sampling

Allocation sampling is suitable for checking "which functions allocate the most memory."

It may not directly prove a leak, but it helps locate positions where objects are created at high frequency.

How to open

  1. Open DevTools
  2. Go to Memory
  3. Select Allocation sampling
  4. Click Start
  5. Perform operations
  6. Click Stop
  7. View Heavy or Bottom-up

Usage example

Results:

createTableRows        80 MB
renderVirtualList      40 MB
parseLargeJson         30 MB

Investigation direction:

Are large arrays created on every render?
Does the list lack virtual scrolling?
Is data being cloned repeatedly?
Is there no cache limit?
Are closures retaining old data?

11. Introduction to the Rendering panel

The Rendering panel is not specifically for checking memory leaks, but it helps discover repaints, layout shifts, compositing layers, and scrolling performance issues.

Memory leaks are often accompanied by:

DOM Nodes increasing
Page repaint areas becoming larger
Scrolling becoming increasingly sluggish
FPS dropping
GPU memory increasing

How to open

  1. Open DevTools
  2. Press Command + Shift + P
  3. On Windows / Linux, it's Control + Shift + P
  4. Type Rendering
  5. Select Show Rendering

Or:

DevTools top-right three dots
-> More tools
-> Rendering

12. Rendering: Paint flashing

Function

When enabled, areas of the page that are repainted will flash green.

Screenshot

Paint Flashing

Usage steps

  1. Open Rendering
  2. Check Paint flashing
  3. Scroll the page, move the mouse, open modals, switch tabs
  4. Observe which areas flash green

Judgment

Normal:

Only changed areas flash green.

Abnormal:

Moving the mouse causes the entire page to flash green.
When scrolling a list, large areas repaint repeatedly.

Common causes:

Frequent modification of width / height / top / left
Animations not using transform / opacity
Scrolling triggers many DOM style updates
The page has a huge repaint area

Optimization example:

/* Not recommended: easily triggers layout and repaint */
.box {
  left: 100px;
}

/* Recommended: more likely to go through compositing */
.box {
  transform: translateX(100px);
}

13. Rendering: Layout Shift Regions

Function

Highlights areas where layout shifts occur, usually shown in purple.

Screenshot

Layout Shift Regions

Usage steps

  1. Open Rendering
  2. Check Layout Shift Regions
  3. Refresh the page
  4. Observe which areas are highlighted in purple

Usage example

Problem:

When the page loads, images load later, causing buttons below to suddenly shift down.

Fix:

<img src="banner.png" width="600" height="300" />

Or:

.banner {
  aspect-ratio: 2 / 1;
}

This reserves space before the image loads, reducing layout shifts.


14. Rendering: Frame rendering stats

Function

Displays real-time frame rate, dropped frames, GPU raster, and GPU memory in the top right corner of the page.

Screenshot

Frame Rendering Stats

Usage steps

  1. Open Rendering
  2. Check Frame rendering stats
  3. Scroll the page or perform animations
  4. Observe the floating panel in the top right

How to read

FPS:
  The closer to 60, the smoother.

Dropped frames:
  Many dropped frames indicate high pressure on the main thread, rendering, or compositing.

GPU memory:
  Continuous increase may indicate excessive usage by layers, canvas, images, or video resources.

Usage example

Problem:

FPS drops from 60 to 20 when scrolling a long list.

Combined with Performance monitor observation:

DOM Nodes: 50000
Layouts / sec: consistently high
Style recalcs / sec: consistently high

Conclusion:

Too many list DOM nodes, and scrolling triggers frequent layout and style recalculations.

Fix direction:

Use a virtual list
Reduce synchronous DOM queries in scroll events
Avoid setState updating many components during scrolling
Use passive listeners

15. Rendering: Layer borders

Function

Shows page compositing layers and tile boundaries, helping to troubleshoot excessive layers and high GPU memory issues.

Usage steps

  1. Open Rendering
  2. Check Layer borders
  3. Observe the boundary lines appearing on the page

Common problems

Many elements using transform
Many elements using will-change
Many fixed / sticky elements
Complex animations causing a surge in layer count

Incorrect example:

.card {
  will-change: transform;
}

If there are hundreds of .card elements on the page, keeping will-change long-term creates extra memory and compositing pressure.

More reasonable:

.card {
  will-change: auto;
}

.card.is-animating {
  will-change: transform;
}

16. Rendering: Scrolling Performance Issues

Function

Highlights elements that may affect scrolling performance, such as event listeners that block scrolling.

Usage steps

  1. Open Rendering
  2. Check Scrolling Performance Issues
  3. Scroll the page
  4. See if highlighted areas appear on the page

Common fixes

If the event listener does not call preventDefault, add passive: true:

window.addEventListener('touchmove', onTouchMove, {
  passive: true,
})

Avoid synchronous DOM read/write in scroll events:

// Not recommended
window.addEventListener('scroll', () => {
  const top = box.getBoundingClientRect().top
  box.style.width = `${top}px`
})

Better approach:

let ticking = false

window.addEventListener('scroll', () => {
  if (ticking) return

  ticking = true
  requestAnimationFrame(() => {
    update()
    ticking = false
  })
}, { passive: true })

17. Complete case study: Modal memory leak

Problem symptoms

After opening and closing the user detail modal multiple times, the page becomes increasingly sluggish.

Step 1: Check trends with Performance monitor

Initial:

JS heap size: 45 MB
DOM Nodes: 3000
JS event listeners: 800

After opening and closing the modal 20 times:

JS heap size: 95 MB
DOM Nodes: 9000
JS event listeners: 2200

Preliminary judgment:

DOM, event listeners, and component instances all show signs of leaking.

Step 2: Heap Snapshot comparison

Operation:

Snapshot 1: Initial state
Snapshot 2: After opening and closing the modal 20 times
Switch view to Comparison

Findings:

Detached HTMLDivElement +400
UserDetailModal +20
ResizeObserver +20

Step 3: Check Retainers

Reference chain:

Window
  -> modalService
    -> activeModals
      -> UserDetailModal
        -> resizeObserver
        -> rootElement
          -> Detached HTMLDivElement

Conclusion:

modalService.activeModals did not delete the modal instance.
ResizeObserver was not disconnected.
rootElement points to a DOM node that has already been removed.

Step 4: Fix the code

Problematic code:

class UserDetailModal {
  constructor(el) {
    this.el = el
    this.resizeObserver = new ResizeObserver(this.handleResize)
    this.resizeObserver.observe(el)

    window.addEventListener('resize', this.handleResize)
  }

  close() {
    document.body.removeChild(this.el)
  }
}

After fix:

class UserDetailModal {
  constructor(el) {
    this.el = el
    this.handleResize = this.handleResize.bind(this)

    this.resizeObserver = new ResizeObserver(this.handleResize)
    this.resizeObserver.observe(el)

    window.addEventListener('resize', this.handleResize)
  }

  close() {
    this.resizeObserver.disconnect()
    window.removeEventListener('resize', this.handleResize)

    this.el.remove()
    this.el = null
  }
}

class ModalService {
  constructor() {
    this.activeModals = new Set()
  }

  open(modal) {
    this.activeModals.add(modal)
  }

  close(modal) {
    modal.close()
    this.activeModals.delete(modal)
  }
}

Step 5: Re-test

After repeating the same operation 20 times:

JS heap size: 50 MB
DOM Nodes: 3100
JS event listeners: 820

Judgment:

Metrics return to near initial values after GC; the leak is essentially fixed.