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:
- The page becomes slower with use
- Memory does not decrease after switching pages
- DOM nodes increase after opening and closing a modal multiple times
- List scrolling becomes slower over time
- Timers, event listeners, Observers, and WebSockets are not released
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:
- Modal managers caching modal instances
- Global Maps caching DOM nodes
- Event callback closures referencing DOM nodes
- Third-party chart library instances not destroyed
- Observers not disconnected
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
- Press
Shift + Escin Chrome - Or click the Chrome menu in the top right
- Select
More tools - Select
Task manager - Right-click the header and check
JavaScript memory
Screenshot
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":
- Open the target page
- Open Task Manager
- Check
JavaScript memory - Record the initial value
- Repeat the operation: open modal -> close modal, 20 times
- See if the live value of
JavaScript Memorycontinuously 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
- Open DevTools
- Press
Command + Shift + P - On Windows / Linux, it's
Control + Shift + P - Type
Performance monitor - Select
Show Performance monitor
Or:
DevTools top-right three dots
-> More tools
-> Performance monitor
Screenshot

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:
- Open Performance monitor
- Record initial values:
JS heap size: 45 MB
DOM Nodes: 3000
JS event listeners: 800
- Repeat the operation 20 times:
Open user detail modal
Close user detail modal
- 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
- Open DevTools
- Go to the
Performancepanel - Check
Memory - Click the record button
- Click the trash/broom button to manually trigger GC
- Perform business operations
- Manually trigger GC again
- Stop recording
Screenshot
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
- Open DevTools
- Go to the
Memorypanel - Select
Heap snapshot - Click
Take snapshot
Screenshot
Recommended workflow
- Refresh the page to reach a stable state
- Manually trigger GC
- Take
Snapshot 1 - Perform the suspicious operation, e.g., open and close a modal 20 times
- Manually trigger GC
- Take
Snapshot 2 - Select
Snapshot 2 - Switch the view to
Comparison - 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
Investigation steps
- Open
Memory - Select
Heap snapshot - Take the first snapshot
- Perform the suspicious operation
- Manually trigger GC
- Take the second snapshot
- In the
Class filter, type:
Detached
- Check for:
Detached HTMLDivElement
Detached HTMLUListElement
Detached HTMLLIElement
Detached HTMLElement
- Click on an object
- Look at the
Retainerspanel 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
- Open DevTools
- Go to
Memory - Select
Allocations on timeline - Click
Start - Perform business operations
- 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
- Open DevTools
- Go to
Memory - Select
Allocation sampling - Click
Start - Perform operations
- Click
Stop - View
HeavyorBottom-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
- Open DevTools
- Press
Command + Shift + P - On Windows / Linux, it's
Control + Shift + P - Type
Rendering - 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

Usage steps
- Open
Rendering - Check
Paint flashing - Scroll the page, move the mouse, open modals, switch tabs
- 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

Usage steps
- Open
Rendering - Check
Layout Shift Regions - Refresh the page
- 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
Usage steps
- Open
Rendering - Check
Frame rendering stats - Scroll the page or perform animations
- 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
- Open
Rendering - Check
Layer borders - 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
- Open
Rendering - Check
Scrolling Performance Issues - Scroll the page
- 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.