跪拜 Guibai
← All articles
Frontend · JavaScript · Interview

Three React useEffect Cleanups You're Probably Skipping — and the Memory Leaks They Cause

By kyriewen ·
Read original on juejin.cn ↗ Google Translate ↗ Alt translation

These leaks survive code review and automated tests because they don't produce errors — they just degrade performance over hours of uptime. A single-page app with unmounted-but-uncleaned chart instances, lingering WebSocket connections, and stale window listeners can leak tens of megabytes per user session, directly hitting Core Web Vitals and user retention on long-running dashboards and data-heavy SPAs.

Summary

Heap snapshot comparisons in Chrome DevTools reveal that three common omissions in useEffect cleanup functions are responsible for steady memory growth in long-running React pages. Window event listeners left unremoved keep closures and component instances alive indefinitely. WebSocket and EventSource connections opened on dependency changes accumulate active connections that continue receiving data and triggering callbacks on unmounted components. Visualization library instances from ECharts, Chart.js, or map libraries hold onto Canvas contexts and internal state trees that React never frees.

The pattern is uniform: any registration operation — addEventListener, new WebSocket, library.init — demands a corresponding deregistration in the cleanup function. Without it, each mount cycle adds objects that garbage collection can never reach. The leaks are invisible during local development and pass code review silently, only manifesting as degraded performance after hours of real-world use.

The fix is mechanical. Return a function from useEffect that calls removeEventListener, close, dispose, or unsubscribe. The mechanism has existed since React's earliest versions; the failure is not technical but habitual — developers simply don't register the need for cleanup at write time.

Takeaways
Every addEventListener on window, document, or any global object must have a corresponding removeEventListener inside the useEffect cleanup return.
WebSocket and EventSource connections opened inside useEffect accumulate with every dependency change unless explicitly closed in the cleanup function.
Third-party library instances created via init or create methods (ECharts, Chart.js, Leaflet, AMap) hold Canvas contexts and internal state that React cannot free; their destroy or dispose methods must be called on unmount.
Heap snapshot comparison in Chrome DevTools — taking snapshots before and after a suspected operation — reliably identifies leaking object types by tracking objects that grow without receding.
setTimeout and setInterval calls inside useEffect require clearTimeout and clearInterval in the cleanup function, following the same registration-deregistration rule.
The leaks are invisible during local development because refreshing the page resets memory; they only appear after extended real-world usage.
Conclusions

The three leak patterns share an identical root cause — a registration without a deregistration — yet developers treat them as separate problems because the APIs look different. Recognizing the abstract pattern eliminates entire categories of leaks at design time rather than debugging them one by one.

Memory leaks from third-party visualization libraries are disproportionately expensive because each instance holds Canvas buffers and off-screen rendering caches that are orders of magnitude larger than typical JavaScript objects. A single undisposed chart instance can cost more memory than a hundred lingering event listeners.

The React team built the useEffect cleanup mechanism from day one, which means the tooling for prevention has existed longer than the problem. The gap is entirely in developer habit formation, not framework capability.

Concepts & terms
Heap snapshot comparison
A Chrome DevTools Memory panel technique where two heap snapshots are taken — one before and one after a suspected leak-causing operation — and compared to identify object types whose instance counts grow without returning to baseline, indicating memory that cannot be garbage collected.
Detached DOM nodes
DOM elements removed from the document tree but still referenced by JavaScript variables or closures, preventing the garbage collector from freeing their memory. They appear in heap snapshots as 'Detached' nodes and are a primary signal of frontend memory leaks.
Closure-based memory retention
When a function defined inside a component captures variables from its outer scope (like state setters), and that function is held alive by an external reference (like a window event listener), the entire component instance and its associated objects remain in memory even after React unmounts the component.
Source: juejin.cn ↗ Google Translate ↗ Backup ↗