Skip to main content
โšก Calmops

Memory Optimization and Leak Detection in JavaScript

Memory Optimization and Leak Detection in JavaScript

Memory management is critical for building performant, stable applications. This article covers understanding JavaScript memory, identifying leaks, optimizing usage, and using DevTools for memory profiling.

Introduction

JavaScript memory issues can cause:

  • Slow performance over time
  • Application crashes
  • High server costs
  • Poor user experience on mobile devices

Understanding memory management helps you:

  • Identify memory leaks early
  • Optimize memory usage
  • Build more stable applications
  • Improve long-running application performance

JavaScript Memory Model

Memory Allocation

// Stack: Stores primitive values and references
let number = 42; // Stack
let string = 'hello'; // Stack
let boolean = true; // Stack

// Heap: Stores objects and arrays
let object = { name: 'John' }; // Reference in stack, object in heap
let array = [1, 2, 3]; // Reference in stack, array in heap
let function_ref = () => {}; // Reference in stack, function in heap

Garbage Collection

// JavaScript uses automatic garbage collection
// Objects are collected when no longer referenced

// Example: Object eligible for garbage collection
function createObject() {
  let obj = { data: new Array(1000000) };
  return obj.data; // obj is no longer referenced
}

// The obj reference is garbage collected after the function returns
const data = createObject();
// Generational garbage collection
// Young generation: Frequently collected
// Old generation: Rarely collected

// Example: Short-lived objects (young generation)
function processData() {
  const temp = new Array(1000); // Created and destroyed quickly
  return temp.length;
}

// Example: Long-lived objects (old generation)
const cache = {}; // Persists for application lifetime

Common Memory Leaks

1. Global Variables

// โŒ Memory leak: Global variable
function createLeak() {
  leakedData = new Array(1000000); // Creates global variable
}

createLeak();
// leakedData persists in memory forever

// โœ… Fix: Use local scope
function noLeak() {
  const data = new Array(1000000); // Local scope
}

2. Forgotten Timers and Callbacks

// โŒ Memory leak: Forgotten interval
function setupInterval() {
  const largeData = new Array(1000000);
  
  setInterval(() => {
    console.log(largeData.length); // largeData kept in closure
  }, 1000);
  
  // Interval never cleared, largeData never garbage collected
}

// โœ… Fix: Clear interval
function setupIntervalCorrectly() {
  const largeData = new Array(1000000);
  
  const intervalId = setInterval(() => {
    console.log(largeData.length);
  }, 1000);
  
  // Clear when done
  setTimeout(() => clearInterval(intervalId), 10000);
}
// โŒ Memory leak: Event listener not removed
function setupListener() {
  const largeData = new Array(1000000);
  
  document.addEventListener('click', () => {
    console.log(largeData.length); // largeData kept in closure
  });
  
  // Listener never removed
}

// โœ… Fix: Remove listener
function setupListenerCorrectly() {
  const largeData = new Array(1000000);
  
  const handler = () => {
    console.log(largeData.length);
  };
  
  document.addEventListener('click', handler);
  
  // Remove when done
  document.removeEventListener('click', handler);
}

3. Detached DOM Nodes

// โŒ Memory leak: Detached DOM reference
function createDetachedNode() {
  const div = document.createElement('div');
  div.id = 'my-div';
  
  // Store reference to detached node
  window.detachedNode = div;
  
  // Even if removed from DOM, reference keeps it in memory
  document.body.appendChild(div);
  div.remove();
  
  // window.detachedNode still references the removed node
}

// โœ… Fix: Clear references
function createDetachedNodeCorrectly() {
  const div = document.createElement('div');
  div.id = 'my-div';
  
  document.body.appendChild(div);
  div.remove();
  
  // Don't keep references to removed nodes
}

4. Circular References

// โŒ Memory leak: Circular reference
function createCircularReference() {
  const obj1 = { name: 'obj1' };
  const obj2 = { name: 'obj2' };
  
  obj1.ref = obj2;
  obj2.ref = obj1; // Circular reference
  
  // Even if both go out of scope, they reference each other
  // Modern garbage collectors handle this, but it's still bad practice
}

// โœ… Fix: Break circular references
function breakCircularReference() {
  const obj1 = { name: 'obj1' };
  const obj2 = { name: 'obj2' };
  
  obj1.ref = obj2;
  obj2.ref = obj1;
  
  // When done, break the cycle
  obj1.ref = null;
  obj2.ref = null;
}

5. Large Cache Without Limits

// โŒ Memory leak: Unbounded cache
const cache = {};

function cacheData(key, value) {
  cache[key] = value; // Cache grows indefinitely
}

// โœ… Fix: Implement cache limits
class LimitedCache {
  constructor(maxSize = 100) {
    this.cache = {};
    this.maxSize = maxSize;
    this.keys = [];
  }

  set(key, value) {
    if (this.cache[key]) {
      // Remove old key from tracking
      this.keys = this.keys.filter(k => k !== key);
    }

    this.cache[key] = value;
    this.keys.push(key);

    // Remove oldest entry if cache is full
    if (this.keys.length > this.maxSize) {
      const oldestKey = this.keys.shift();
      delete this.cache[oldestKey];
    }
  }

  get(key) {
    return this.cache[key];
  }
}

// Usage
const cache = new LimitedCache(100);
cache.set('user-1', { name: 'John' });

Memory Profiling with DevTools

Heap Snapshots

// Steps to take a heap snapshot:
// 1. Open DevTools โ†’ Memory tab
// 2. Select "Heap snapshot"
// 3. Click "Take snapshot"
// 4. Perform actions
// 5. Take another snapshot
// 6. Compare snapshots

// Example: Code to profile
class DataStore {
  constructor() {
    this.data = [];
  }

  addData(items) {
    this.data.push(...items);
  }

  clear() {
    this.data = [];
  }
}

const store = new DataStore();

// Add data
for (let i = 0; i < 1000; i++) {
  store.addData(new Array(10000).fill(i));
}

// Take snapshot here to see memory usage

Allocation Timeline

// Steps to use allocation timeline:
// 1. Open DevTools โ†’ Memory tab
// 2. Select "Allocation timeline"
// 3. Click "Start"
// 4. Perform actions
// 5. Click "Stop"
// 6. Analyze the timeline

// Example: Monitoring memory during operations
function allocateMemory() {
  const arrays = [];
  
  for (let i = 0; i < 100; i++) {
    arrays.push(new Array(100000).fill(i));
  }
  
  return arrays;
}

// Allocation timeline shows memory spikes
const result = allocateMemory();

Memory Leak Detection

// Steps to detect memory leaks:
// 1. Take initial heap snapshot
// 2. Perform action that might leak
// 3. Take another snapshot
// 4. Compare snapshots
// 5. Look for objects that should have been garbage collected

// Example: Detecting a leak
let leakedArray = [];

function createLeak() {
  const data = new Array(1000000).fill('data');
  leakedArray.push(data);
}

// Take snapshot before
// Call createLeak() multiple times
// Take snapshot after
// Compare to see if memory grows

Memory Optimization Techniques

1. Nullify References

// โœ… Good: Clear references when done
function processLargeData() {
  let largeData = new Array(1000000);
  
  // Use largeData
  const result = largeData.length;
  
  // Clear reference
  largeData = null;
  
  return result;
}

2. Use WeakMap and WeakSet

// WeakMap: Keys are weakly referenced
const weakMap = new WeakMap();

const obj = { id: 1 };
weakMap.set(obj, 'metadata');

// When obj is garbage collected, the entry is removed
// This prevents memory leaks from forgotten references

// Example: Caching with WeakMap
class ObjectCache {
  constructor() {
    this.cache = new WeakMap();
  }

  set(obj, value) {
    this.cache.set(obj, value);
  }

  get(obj) {
    return this.cache.get(obj);
  }
}

// Usage
const cache = new ObjectCache();
const user = { id: 1, name: 'John' };
cache.set(user, { lastAccess: Date.now() });

// When user is garbage collected, cache entry is removed automatically

3. Avoid Memory Thrashing

// โŒ Bad: Creating many temporary objects
function badApproach() {
  const results = [];
  
  for (let i = 0; i < 1000000; i++) {
    const obj = { value: i }; // Creates 1 million objects
    results.push(obj);
  }
  
  return results;
}

// โœ… Good: Reuse objects
function goodApproach() {
  const results = [];
  const obj = { value: 0 };
  
  for (let i = 0; i < 1000000; i++) {
    obj.value = i;
    results.push({ ...obj }); // Only copy when needed
  }
  
  return results;
}

4. Lazy Loading

// โœ… Good: Load data only when needed
class LazyDataLoader {
  constructor(dataSource) {
    this.dataSource = dataSource;
    this.data = null;
  }

  async getData() {
    if (!this.data) {
      this.data = await this.dataSource.fetch();
    }
    return this.data;
  }

  clearCache() {
    this.data = null;
  }
}

// Usage
const loader = new LazyDataLoader(apiClient);
const data = await loader.getData(); // Loads on first access

5. Object Pooling

// โœ… Good: Reuse objects instead of creating new ones
class ObjectPool {
  constructor(factory, resetFn, initialSize = 10) {
    this.factory = factory;
    this.resetFn = resetFn;
    this.available = [];
    this.inUse = new Set();

    // Pre-allocate objects
    for (let i = 0; i < initialSize; i++) {
      this.available.push(factory());
    }
  }

  acquire() {
    let obj;
    if (this.available.length > 0) {
      obj = this.available.pop();
    } else {
      obj = this.factory();
    }
    this.inUse.add(obj);
    return obj;
  }

  release(obj) {
    if (this.inUse.has(obj)) {
      this.inUse.delete(obj);
      this.resetFn(obj);
      this.available.push(obj);
    }
  }
}

// Usage
const pool = new ObjectPool(
  () => ({ x: 0, y: 0 }), // Factory
  (obj) => { obj.x = 0; obj.y = 0; } // Reset function
);

const point = pool.acquire();
point.x = 10;
point.y = 20;
pool.release(point); // Reused for next acquire

Practical Examples

Memory-Efficient Event Listener

class EventManager {
  constructor() {
    this.listeners = new Map();
  }

  on(element, event, handler) {
    if (!this.listeners.has(element)) {
      this.listeners.set(element, new Map());
    }

    const elementListeners = this.listeners.get(element);
    if (!elementListeners.has(event)) {
      elementListeners.set(event, []);
    }

    elementListeners.get(event).push(handler);
    element.addEventListener(event, handler);
  }

  off(element, event, handler) {
    element.removeEventListener(event, handler);

    if (this.listeners.has(element)) {
      const elementListeners = this.listeners.get(element);
      if (elementListeners.has(event)) {
        const handlers = elementListeners.get(event);
        const index = handlers.indexOf(handler);
        if (index > -1) {
          handlers.splice(index, 1);
        }
      }
    }
  }

  cleanup(element) {
    if (this.listeners.has(element)) {
      const elementListeners = this.listeners.get(element);
      elementListeners.forEach((handlers, event) => {
        handlers.forEach(handler => {
          element.removeEventListener(event, handler);
        });
      });
      this.listeners.delete(element);
    }
  }
}

// Usage
const manager = new EventManager();
const button = document.getElementById('button');

manager.on(button, 'click', () => console.log('Clicked'));
// Later, cleanup
manager.cleanup(button);

Memory-Efficient Data Processing

// Process large datasets without loading everything into memory
async function* processLargeDataset(url) {
  const response = await fetch(url);
  const reader = response.body.getReader();
  const decoder = new TextDecoder();
  let buffer = '';

  while (true) {
    const { done, value } = await reader.read();
    if (done) break;

    buffer += decoder.decode(value, { stream: true });
    const lines = buffer.split('\n');
    buffer = lines.pop(); // Keep incomplete line

    for (const line of lines) {
      if (line.trim()) {
        yield JSON.parse(line);
      }
    }
  }
}

// Usage
for await (const item of processLargeDataset('/api/large-dataset')) {
  console.log(item); // Process one item at a time
}

Best Practices

  1. Profile before optimizing:

    // โœ… Good - measure first
    const monitor = new PerformanceMonitor();
    
  2. Clean up resources:

    // โœ… Good - remove listeners and timers
    element.removeEventListener('click', handler);
    clearInterval(intervalId);
    
  3. Use WeakMap for metadata:

    // โœ… Good - prevents memory leaks
    const metadata = new WeakMap();
    
  4. Limit cache sizes:

    // โœ… Good - bounded cache
    const cache = new LimitedCache(100);
    

Common Mistakes

  1. Not removing event listeners:

    // โŒ Bad
    element.addEventListener('click', handler);
    
    // โœ… Good
    element.removeEventListener('click', handler);
    
  2. Keeping references to removed DOM nodes:

    // โŒ Bad
    window.node = element;
    element.remove();
    
    // โœ… Good
    element.remove();
    // Don't keep references
    
  3. Unbounded caches:

    // โŒ Bad
    const cache = {};
    cache[key] = value; // Grows forever
    
    // โœ… Good
    const cache = new LimitedCache(100);
    

Summary

Memory management is crucial for building performant applications. Key takeaways:

  • Understand JavaScript memory model (stack vs heap)
  • Identify common memory leak patterns
  • Use DevTools for memory profiling
  • Clean up resources (listeners, timers, references)
  • Use WeakMap for metadata
  • Implement bounded caches
  • Profile and optimize based on data

Next Steps

Comments