Next.js starter your AI actually understands. Ship internal tools in days not weeks. Pre-order $199 $499 → [Get it now]

How to avoid memory leaks in JavaScript

Memory leaks occur when JavaScript retains references to objects that are no longer needed, preventing garbage collection and causing memory usage to grow indefinitely. As the creator of CoreUI with 26 years of JavaScript development experience, I’ve debugged memory leaks in applications serving millions of users, reducing memory consumption from 500MB to 50MB by properly managing event listeners, timers, and closures.

The most effective approach combines proper cleanup patterns with Chrome DevTools memory profiling.

Remove Event Listeners

// Bad - listener never removed
function BadComponent() {
  const button = document.querySelector('#myButton')

  button.addEventListener('click', () => {
    console.log('Clicked')
  })

  // When component unmounts, listener stays in memory
}

// Good - remove listener on cleanup
class GoodComponent {
  constructor() {
    this.button = document.querySelector('#myButton')
    this.handleClick = this.handleClick.bind(this)
    this.button.addEventListener('click', this.handleClick)
  }

  handleClick() {
    console.log('Clicked')
  }

  destroy() {
    this.button.removeEventListener('click', this.handleClick)
  }
}

// Best - AbortController for automatic cleanup
function BestComponent() {
  const button = document.querySelector('#myButton')
  const controller = new AbortController()

  button.addEventListener('click', () => {
    console.log('Clicked')
  }, { signal: controller.signal })

  // Cleanup all listeners at once
  return {
    destroy: () => controller.abort()
  }
}

Clear Timers and Intervals

// Bad - timer keeps running after component removed
function BadTimer() {
  setInterval(() => {
    console.log('Tick')
    updateUI()
  }, 1000)
}

// Good - clear timer on cleanup
class GoodTimer {
  constructor() {
    this.timerId = setInterval(() => {
      console.log('Tick')
      this.updateUI()
    }, 1000)
  }

  updateUI() {
    // Update logic
  }

  destroy() {
    clearInterval(this.timerId)
  }
}

// React example
function TimerComponent() {
  useEffect(() => {
    const timerId = setInterval(() => {
      console.log('Tick')
    }, 1000)

    // Cleanup function
    return () => clearInterval(timerId)
  }, [])

  return <div>Timer running</div>
}

Avoid Closures Retaining Large Objects

// Bad - closure holds entire data array in memory
function processData(data) {
  // data is huge array
  const hugeArray = data.map(item => ({ ...item, processed: true }))

  // This closure keeps hugeArray in memory forever
  return function() {
    console.log(`Processed ${hugeArray.length} items`)
  }
}

// Good - extract only needed data
function processData(data) {
  const hugeArray = data.map(item => ({ ...item, processed: true }))
  const count = hugeArray.length // Extract only what's needed

  // Closure only holds count, not entire array
  return function() {
    console.log(`Processed ${count} items`)
  }
}

// Better - no closure at all
function processData(data) {
  const hugeArray = data.map(item => ({ ...item, processed: true }))
  const count = hugeArray.length

  return { count, summary: `Processed ${count} items` }
}

Clear DOM References

// Bad - keeps DOM nodes in memory
class BadCache {
  constructor() {
    this.cache = {}
  }

  store(id, element) {
    this.cache[id] = element // Holds DOM reference
  }

  get(id) {
    return this.cache[id]
  }
}

// When element removed from DOM, it's still in cache

// Good - use WeakMap for auto cleanup
class GoodCache {
  constructor() {
    this.cache = new WeakMap()
  }

  store(element, data) {
    this.cache.set(element, data)
    // When element is removed from DOM and no other references exist,
    // WeakMap entry is automatically garbage collected
  }

  get(element) {
    return this.cache.get(element)
  }
}

// Usage
const cache = new GoodCache()
const button = document.querySelector('#myButton')
cache.store(button, { clicks: 0 })

// When button removed, cache entry auto-cleans
button.remove()

Clean Up Observers

// Bad - observer never disconnected
function BadObserver() {
  const observer = new MutationObserver(mutations => {
    console.log('DOM changed')
  })

  observer.observe(document.body, { childList: true })
  // Never disconnected - keeps observing forever
}

// Good - disconnect on cleanup
class GoodObserver {
  constructor() {
    this.observer = new MutationObserver(mutations => {
      console.log('DOM changed')
    })

    this.observer.observe(document.body, { childList: true })
  }

  destroy() {
    this.observer.disconnect()
  }
}

// IntersectionObserver example
class LazyLoader {
  constructor() {
    this.observer = new IntersectionObserver(entries => {
      entries.forEach(entry => {
        if (entry.isIntersecting) {
          this.loadImage(entry.target)
          this.observer.unobserve(entry.target)
        }
      })
    })
  }

  observe(element) {
    this.observer.observe(element)
  }

  destroy() {
    this.observer.disconnect()
  }

  loadImage(img) {
    img.src = img.dataset.src
  }
}

Break Circular References

// Bad - circular references prevent GC
function createCircular() {
  const obj1 = { name: 'obj1' }
  const obj2 = { name: 'obj2' }

  obj1.ref = obj2
  obj2.ref = obj1

  return obj1
  // Both objects kept in memory due to circular refs
}

// Good - break references on cleanup
class Node {
  constructor(value) {
    this.value = value
    this.next = null
  }

  destroy() {
    // Break circular references
    let current = this
    while (current) {
      const next = current.next
      current.next = null
      current = next
    }
  }
}

// Usage
const head = new Node(1)
head.next = new Node(2)
head.next.next = new Node(3)

// Cleanup
head.destroy()

Avoid Global Variables

// Bad - pollutes global scope
var cache = []
var userData = {}

function addData(data) {
  cache.push(data)
  userData[data.id] = data
  // These grow indefinitely
}

// Good - scoped with cleanup
class DataManager {
  constructor() {
    this.cache = []
    this.userData = new Map()
  }

  addData(data) {
    this.cache.push(data)
    this.userData.set(data.id, data)
  }

  clear() {
    this.cache = []
    this.userData.clear()
  }

  destroy() {
    this.clear()
    this.cache = null
    this.userData = null
  }
}

// Usage
const manager = new DataManager()
manager.addData({ id: 1, name: 'Test' })

// Cleanup
manager.destroy()

Detect Memory Leaks with Chrome DevTools

// 1. Open Chrome DevTools > Memory tab
// 2. Take heap snapshot
// 3. Perform actions that might leak
// 4. Take another snapshot
// 5. Compare snapshots

// Example: Test for leaks
class ComponentTester {
  constructor() {
    this.instances = []
  }

  createInstances(count) {
    for (let i = 0; i < count; i++) {
      this.instances.push(new MyComponent())
    }
  }

  destroyInstances() {
    this.instances.forEach(instance => instance.destroy())
    this.instances = []
  }
}

// Test procedure:
// 1. Take baseline snapshot
// 2. Create 100 components
// 3. Take snapshot (memory should increase)
// 4. Destroy all components
// 5. Force GC (trash icon in DevTools)
// 6. Take final snapshot
// 7. Memory should return to baseline

// If memory doesn't return, there's a leak

Profile Memory Usage

// Use performance.memory (Chrome only)
if (performance.memory) {
  console.log('Used JS Heap:', performance.memory.usedJSHeapSize)
  console.log('Total JS Heap:', performance.memory.totalJSHeapSize)
  console.log('Heap Limit:', performance.memory.jsHeapSizeLimit)
}

// Monitor memory over time
class MemoryMonitor {
  constructor() {
    this.samples = []
    this.intervalId = null
  }

  start(intervalMs = 1000) {
    this.intervalId = setInterval(() => {
      if (performance.memory) {
        this.samples.push({
          timestamp: Date.now(),
          used: performance.memory.usedJSHeapSize,
          total: performance.memory.totalJSHeapSize
        })
      }
    }, intervalMs)
  }

  stop() {
    clearInterval(this.intervalId)
  }

  getReport() {
    const first = this.samples[0]
    const last = this.samples[this.samples.length - 1]
    const growth = last.used - first.used
    const duration = last.timestamp - first.timestamp

    return {
      duration,
      initialMemory: first.used,
      finalMemory: last.used,
      growth,
      growthRate: growth / (duration / 1000) // bytes per second
    }
  }
}

// Usage
const monitor = new MemoryMonitor()
monitor.start()

// Run your app...

setTimeout(() => {
  monitor.stop()
  console.log(monitor.getReport())
}, 60000)

Best Practice Note

This is how we prevent memory leaks across all CoreUI JavaScript applications. Memory leaks gradually degrade performance and can crash applications running for extended periods. Always remove event listeners when components unmount, clear timers and intervals, avoid closures that retain large objects, use WeakMap for caching DOM references, disconnect observers when done, break circular references, and regularly profile memory usage in Chrome DevTools. In React, use cleanup functions in useEffect; in vanilla JS, implement destroy methods for all components.

For production applications, consider using CoreUI’s Admin Templates which include memory-safe component patterns.

For related performance optimization, check out how to optimize array operations in JavaScript and how to profile JavaScript performance.


Speed up your responsive apps and websites with fully-featured, ready-to-use open-source admin panel templates—free to use and built for efficiency.


About the Author