Node.js Memory Leaks: The Ultimate Debugging Guide for Senior Engineers

Heap Snapshots, GC Tuning, and Fixing Leaky EventEmitters in Production

Introduction

Memory leaks in Node.js applications can be particularly insidious. They start small and often go unnoticed in development environments, only to manifest as catastrophic production outages under load. As a senior engineer, diagnosing and resolving these issues efficiently can be the difference between a minor hiccup and a major service disruption.

This guide draws from years of production experience debugging memory leaks in high-traffic Node.js services. We’ll go beyond the basics to explore advanced techniques for memory leak detection, diagnosis, and resolution—with a particular focus on the tools and methodologies that work in real-world scenarios.

Understanding Node.js Memory Architecture

Before diving into debugging techniques, it’s crucial to understand how memory management works in Node.js.

The V8 Memory Model

Node.js uses Google’s V8 JavaScript engine, which divides memory into several segments:

  • Young Generation (New Space): Where new objects are allocated. This space is small and designed for quick garbage collection.
  • Old Generation (Old Space): Where objects that survive multiple garbage collection cycles in the Young Generation are moved.
  • Large Object Space: For objects exceeding the size limits of other spaces.
  • Code Space: Where compiled code is stored.
  • Cell Space, Property Cell Space, and Map Space: For internal V8 structures.

Garbage Collection in V8

V8 employs a generational garbage collection strategy:

  1. Scavenger (Minor GC): A fast collector that operates on the Young Generation.
  2. Mark-Sweep-Compact (Major GC): A more thorough but slower collector that processes the entire heap.

Understanding this model is essential because memory leaks typically manifest as objects unintentionally retained in the Old Generation, preventing them from being garbage collected.

Identifying Memory Leaks

Recognizing the Symptoms

Memory leaks in Node.js applications typically manifest through:

  • Steadily increasing memory usage over time
  • Degrading performance as garbage collection runs more frequently
  • FATAL ERROR: Committing semi space failed. Allocation failed errors
  • Increased swap usage and eventually out-of-memory crashes

Monitoring Memory Usage

The first step is establishing proper monitoring. Beyond basic system metrics, implement these Node.js-specific approaches:

// Basic memory usage logging
const logMemoryUsage = () => {
  const used = process.memoryUsage();
  console.log({
    rss: `${Math.round(used.rss / 1024 / 1024)} MB`,
    heapTotal: `${Math.round(used.heapTotal / 1024 / 1024)} MB`,
    heapUsed: `${Math.round(used.heapUsed / 1024 / 1024)} MB`,
    external: `${Math.round(used.external / 1024 / 1024)} MB`,
  });
};

// Log every 5 minutes
setInterval(logMemoryUsage, 5 * 60 * 1000);

For production environments, implement more sophisticated monitoring:

const memwatch = require('@airbnb/node-memwatch');

// Log when GC happens
memwatch.on('stats', (stats) => {
  logger.info('GC Stats:', stats);
});

// Detect potential memory leaks
memwatch.on('leak', (info) => {
  logger.warn('Memory leak detected:', info);
  
  // Optionally trigger heap snapshot on leak detection
  if (process.env.NODE_ENV === 'production') {
    triggerHeapSnapshot();
  }
});

Setting Up Memory Thresholds

For production services, configure automatic actions when memory thresholds are exceeded:

const MAX_HEAP_USED_MB = 1500;

const memoryCheckInterval = setInterval(() => {
  const memUsage = process.memoryUsage();
  const heapUsedMB = memUsage.heapUsed / 1024 / 1024;
  
  if (heapUsedMB > MAX_HEAP_USED_MB) {
    logger.error(`Memory threshold exceeded: ${heapUsedMB.toFixed(2)} MB`);
    
    // Take emergency action
    captureHeapSnapshot(`threshold-exceeded-${Date.now()}.heapsnapshot`);
    
    // Optional: Force garbage collection if --expose-gc flag is used
    if (global.gc) {
      logger.info('Forcing garbage collection');
      global.gc();
    }
    
    // In extreme cases, consider graceful restart
    if (heapUsedMB > MAX_HEAP_USED_MB * 1.5) {
      logger.fatal('Critical memory threshold exceeded, initiating restart');
      process.kill(process.pid, 'SIGTERM');
    }
  }
}, 30000);

memoryCheckInterval.unref(); // Don't keep the process alive just for this timer

Heap Snapshots: Your Most Powerful Tool

Heap snapshots provide a detailed view of memory allocation at a specific point in time, making them invaluable for leak detection.

Capturing Heap Snapshots

You can capture snapshots programmatically:

const v8 = require('v8');
const fs = require('fs');
const path = require('path');

function captureHeapSnapshot(filename) {
  const snapshotStream = v8.getHeapSnapshot();
  const filePath = path.join(process.env.HEAP_SNAPSHOT_DIR || '/tmp', filename);
  
  const fileStream = fs.createWriteStream(filePath);
  snapshotStream.pipe(fileStream);
  
  return new Promise((resolve, reject) => {
    fileStream.on('finish', () => {
      logger.info(`Heap snapshot written to ${filePath}`);
      resolve(filePath);
    });
    fileStream.on('error', reject);
  });
}

For production systems, implement a strategy to capture snapshots at critical moments:

  1. Time-based snapshots to track memory growth patterns
  2. Signal-triggered snapshots for on-demand analysis
  3. Threshold-based snapshots when memory usage spikes
// Handle SIGUSR2 for on-demand snapshots
process.on('SIGUSR2', () => {
  logger.info('Received SIGUSR2, capturing heap snapshot');
  captureHeapSnapshot(`manual-${Date.now()}.heapsnapshot`);
});

// Differential snapshots for analyzing growth
let lastSnapshotTime = Date.now();
async function captureDifferentialSnapshots() {
  // Capture a sequence of snapshots with forced GC in between
  if (global.gc) global.gc();
  await captureHeapSnapshot(`diff-base-${Date.now()}.heapsnapshot`);
  
  // Wait for normal operations to continue
  await new Promise(resolve => setTimeout(resolve, 2 * 60 * 1000));
  
  if (global.gc) global.gc();
  await captureHeapSnapshot(`diff-growth-${Date.now()}.heapsnapshot`);
}

Analyzing Heap Snapshots

Chrome DevTools provides excellent tools for snapshot analysis:

  1. Load your snapshot in Chrome DevTools Memory tab
  2. Focus on the “Comparison” view when analyzing multiple snapshots
  3. Look for objects with large “Retained Size”
  4. Examine object counts that grow between snapshots

Advanced Filtering Techniques

When dealing with large snapshots, use these filtering techniques:

  • Filter by constructor name (e.g., Object, Array, Buffer)
  • Use the “Objects allocated between snapshots” view
  • Examine retaining paths to understand why objects aren’t being garbage collected

Finding Leaked Closures

Closures are a common source of memory leaks. In the heap snapshot:

  1. Filter for “(closure)” in the search box
  2. Look for unexpectedly large numbers of the same closure
  3. Examine the retaining path to identify the source

Detecting Detached DOM in Server-Side Rendering

If you’re using server-side rendering libraries that manipulate DOM-like structures:

  1. Look for large trees of objects with parent-child relationships
  2. Check for circular references that prevent garbage collection
  3. Search for the specific components that might be causing the issues

Garbage Collection Tuning

While fixing the root cause is always preferable, GC tuning can buy you time in critical situations.

Running Node.js with GC Flags

# Increase GC frequency to reduce memory footprint
NODE_OPTIONS="--max-old-space-size=4096 --optimize-for-size" node server.js

# Expose GC for manual triggering and detailed logging
NODE_OPTIONS="--expose-gc --trace-gc" node server.js

# For critical production issues
NODE_OPTIONS="--max-old-space-size=8192 --expose-gc --trace-gc-verbose" node server.js

Understanding and Interpreting GC Logs

When running with --trace-gc, you’ll see output like:

[39631:0x7f2bda5dd580]       44 ms: Scavenge 2.2 (3.2) -> 1.9 (4.2) MB, 0.9 / 0.0 ms  (average mu = 0.994, current mu = 0.994) allocation failure

Key metrics to watch:

  • GC frequency: If minor GCs happen too often, you may have allocation churn
  • Major GC duration: Long pauses indicate large object graphs being processed
  • Memory reduction: Small reclamation from major GCs suggests leaked objects

Manual Garbage Collection in Critical Scenarios

While generally not recommended, you can trigger manual GC in specific scenarios:

// Graceful request handling with memory management
app.use(async (req, res, next) => {
  try {
    await next();
  } finally {
    // Clean up large objects explicitly
    req.largeData = null;
    
    // For batch operations that process large amounts of data
    if (global.gc && process.memoryUsage().heapUsed > THRESHOLD_MB * 1024 * 1024) {
      global.gc();
    }
  }
});

Common Memory Leak Sources and Solutions

The EventEmitter Culprit

EventEmitters are among the most common sources of memory leaks in Node.js. Here’s how to fix them:

1. Implement proper cleanup for listeners

// Problematic pattern
function setupSocket(socket) {
  const handler = (data) => processData(socket, data);
  eventEmitter.on('data', handler); // Never removed!
}

// Correct implementation
function setupSocket(socket) {
  const handler = (data) => processData(socket, data);
  eventEmitter.on('data', handler);
  
  socket.on('close', () => {
    eventEmitter.removeListener('data', handler);
  });
}

2. Use the once method for one-time handlers

// Instead of adding and removing manually
eventEmitter.once('response', handleOneTimeResponse);

3. Set maximum listeners when appropriate

// Increase limit when you know you'll have many listeners
emitter.setMaxListeners(25);

// Or monitor when approaching the limit
const originalAddListener = emitter.addListener;
emitter.addListener = emitter.on = function(type, listener) {
  const currentCount = this.listenerCount(type);
  const maxListeners = this.getMaxListeners();
  
  if (currentCount >= maxListeners - 1) {
    logger.warn(`Warning: emitter approaching max listeners (${currentCount}/${maxListeners}) for event "${type}"`);
    console.trace('Listener addition trace');
  }
  
  return originalAddListener.call(this, type, listener);
};

4. Real-World Example: Fixing a Production EventEmitter Leak

We encountered a significant memory leak in a service processing millions of events daily. The diagnosis and fix illustrates a typical leak pattern:

// The problem: Event handlers bound to long-lived emitter
class MessageProcessor {
  constructor(messageQueue) {
    // This listener was never removed when MessageProcessor instances were disposed
    messageQueue.on('message', this.handleMessage.bind(this));
  }
  
  handleMessage(message) {
    // Process message...
  }
}

// The solution: Track and clean up listeners
class MessageProcessor {
  constructor(messageQueue) {
    this.messageQueue = messageQueue;
    this.boundHandleMessage = this.handleMessage.bind(this);
    this.messageQueue.on('message', this.boundHandleMessage);
  }
  
  handleMessage(message) {
    // Process message...
  }
  
  // Explicit cleanup method
  dispose() {
    this.messageQueue.removeListener('message', this.boundHandleMessage);
    // Clean up other references
    this.messageQueue = null;
  }
}

Timer Reference Leaks

Timers that aren’t cleared can prevent garbage collection of their enclosing contexts:

// Problematic pattern
function startPolling(client) {
  setInterval(() => {
    client.poll();
  }, 5000); // Never cleared!
}

// Fix: Track and clear timers
function startPolling(client) {
  const timerId = setInterval(() => {
    client.poll();
  }, 5000);
  
  client.on('disconnect', () => {
    clearInterval(timerId);
  });
  
  // Store reference for explicit cleanup
  client.pollingTimer = timerId;
}

Caches Without Size Limits

Unbounded caches are a frequent source of memory leaks:

// Problematic unbounded cache
const userCache = {};

function getUser(id) {
  if (!userCache[id]) {
    userCache[id] = fetchUserFromDatabase(id);
  }
  return userCache[id];
}

// Solution: Use a proper LRU cache with limits
const LRU = require('lru-cache');
const userCache = new LRU({
  max: 1000, // Maximum items
  maxAge: 1000 * 60 * 10, // TTL: 10 minutes
  updateAgeOnGet: true, // Reset TTL on access
  sizeCalculation: (value, key) => {
    // Optional: Calculate size based on object properties
    return JSON.stringify(value).length;
  },
});

Leaky Promises and Async Functions

Uncaught promise rejections and orphaned promises can cause memory leaks:

// Problematic pattern: Orphaned promises
function processData(data) {
  fetchRelatedData(data).then(related => {
    // This promise chain is never awaited or caught
    processMoreData(related);
  });
}

// Fix: Always return promises and handle rejections
function processData(data) {
  return fetchRelatedData(data)
    .then(related => processMoreData(related))
    .catch(err => {
      logger.error('Error processing data:', err);
      metrics.increment('data_processing_error');
    });
}

Circular References in Complex Objects

Complex object graphs with circular references can confuse the garbage collector:

// Problematic circular references
function createUserWithPosts(userData, postsData) {
  const user = {
    ...userData,
    posts: []
  };
  
  const posts = postsData.map(postData => ({
    ...postData,
    user: user // Circular reference!
  }));
  
  user.posts = posts;
  return user;
}

// Fix: Use WeakMap for reverse references or restructure data
function createUserWithPosts(userData, postsData) {
  const user = {
    ...userData,
    posts: []
  };
  
  const posts = postsData.map(postData => ({
    ...postData,
    userId: userData.id // Reference by ID instead of object
  }));
  
  user.posts = posts;
  return user;
}

Production Debugging Strategies

Creating a Memory Leak Reproduction Environment

Reproducing memory leaks in development can be challenging. These steps help create a controlled environment:

  1. Isolate the service experiencing the leak
  2. Clone production traffic using tools like tcpreplay or service-specific replay mechanisms
  3. Accelerate the leak by increasing the relevant traffic patterns
  4. Implement detailed memory instrumentation specific to the suspected area

Strategies for Live Production Debugging

Sometimes you must diagnose leaks in production without disrupting service:

  1. Rolling deployment of instrumented instances
    • Deploy special debug instances with additional instrumentation
    • Route a small percentage of traffic to these instances
    • Gather heap snapshots and metrics without affecting most users
  2. Shadow production
    • Create a parallel deployment that receives copies of production traffic
    • Apply more aggressive debugging techniques on this shadow environment
    • Compare memory patterns with the main production environment
  3. Incremental Module Disabling
    • If possible, systematically disable suspect modules in canary instances
    • Observe if memory growth patterns change when specific functionality is disabled

Emergency Mitigation Techniques

When facing a critical memory leak in production without an immediate fix:

// Implement emergency memory monitor
const CRITICAL_MEMORY_MB = 1800;
const WARNING_MEMORY_MB = 1500;
const RESTART_THRESHOLD_MB = 2000;

// Monitor memory and take action as needed
setInterval(() => {
  const memUsageMB = process.memoryUsage().heapUsed / 1024 / 1024;
  
  if (memUsageMB > RESTART_THRESHOLD_MB) {
    logger.fatal(`Memory usage critical: ${memUsageMB.toFixed(2)} MB. Initiating restart.`);
    // Signal health check failures to allow orchestrator to replace instance
    app.set('serviceDegraded', true);
    // Initiate graceful shutdown
    process.kill(process.pid, 'SIGTERM');
  } else if (memUsageMB > CRITICAL_MEMORY_MB) {
    logger.error(`Memory usage high: ${memUsageMB.toFixed(2)} MB. Forcing garbage collection.`);
    if (global.gc) global.gc();
    // Temporarily reject new connections if supported by your framework
    app.set('serviceOverloaded', true);
  } else if (memUsageMB > WARNING_MEMORY_MB) {
    logger.warn(`Memory usage elevated: ${memUsageMB.toFixed(2)} MB`);
    if (global.gc) global.gc();
  }
}, 15000);

Advanced Debugging with Custom Tools

Building a Memory Leak Detection Agent

For complex applications, consider implementing a custom memory leak detection agent:

class MemoryLeakDetector {
  constructor(options = {}) {
    this.heapDiffInterval = options.diffInterval || 5 * 60 * 1000;
    this.snapshotDir = options.snapshotDir || '/tmp';
    this.watchObjects = new Map();
    this.memwatch = require('@airbnb/node-memwatch');
    this.lastHeapSnapshot = null;
    
    // Start monitoring
    this.startMonitoring();
  }
  
  startMonitoring() {
    // Listen for potential leak events
    this.memwatch.on('leak', (info) => {
      logger.warn('Potential memory leak detected', info);
      this.captureHeapSnapshot('leak-detected');
    });
    
    // Take differential heap snapshots
    setInterval(() => this.takeDifferentialSnapshot(), this.heapDiffInterval);
    
    // Watch specific objects for unexpected growth
    setInterval(() => this.checkWatchedObjects(), 60000);
  }
  
  // Register objects to watch for growth
  watchObject(name, objectRef, thresholdBytes = 1024 * 1024) {
    this.watchObjects.set(name, {
      ref: objectRef,
      threshold: thresholdBytes,
      lastSize: this.approximateObjectSize(objectRef)
    });
  }
  
  // Check watched objects for growth
  checkWatchedObjects() {
    for (const [name, details] of this.watchObjects.entries()) {
      const currentSize = this.approximateObjectSize(details.ref);
      const growth = currentSize - details.lastSize;
      
      if (growth > details.threshold) {
        logger.warn(`Watched object "${name}" grew by ${(growth/1024/1024).toFixed(2)} MB`);
        this.captureHeapSnapshot(`object-growth-${name}`);
      }
      
      // Update last size
      details.lastSize = currentSize;
    }
  }
  
  // Take snapshots at interval and compare
  async takeDifferentialSnapshot() {
    if (global.gc) global.gc();
    
    const snapshotPath = path.join(
      this.snapshotDir, 
      `heap-${Date.now()}.heapsnapshot`
    );
    
    await this.captureHeapSnapshot(snapshotPath);
    
    // Optional: implement automatic analysis here
  }
  
  // Utility to approximate object size
  approximateObjectSize(obj) {
    // Implementation depends on object type
    // For simple objects, JSON size is a rough approximation
    try {
      return JSON.stringify(obj).length;
    } catch (e) {
      // Circular references or non-serializable objects
      return 0;
    }
  }
  
  // Capture a heap snapshot
  captureHeapSnapshot(identifier) {
    const filename = `${identifier}-${Date.now()}.heapsnapshot`;
    return captureHeapSnapshot(filename);
  }
}

// Initialize the detector
const memLeakDetector = new MemoryLeakDetector({
  diffInterval: 10 * 60 * 1000, // 10 minutes
  snapshotDir: process.env.SNAPSHOT_DIR || '/tmp'
});

// Register important objects to watch
memLeakDetector.watchObject('userCache', userCache, 5 * 1024 * 1024);
memLeakDetector.watchObject('requestQueue', requestQueue, 10 * 1024 * 1024);

Tracking Object Allocations

For pinpointing specific allocation sites:

function createAllocationTracker(constructor) {
  const originalConstructor = global[constructor];
  
  // Save original
  if (!originalConstructor) return false;
  
  // Track allocations
  global[constructor] = function(...args) {
    const instance = new originalConstructor(...args);
    
    // Only sample a percentage of allocations to reduce overhead
    if (Math.random() < 0.01) { // 1% sampling
      const stack = new Error().stack;
      allocationTracker.track(constructor, stack, instance);
    }
    
    return instance;
  };
  
  // Copy prototype and properties
  global[constructor].prototype = originalConstructor.prototype;
  Object.defineProperties(
    global[constructor], 
    Object.getOwnPropertyDescriptors(originalConstructor)
  );
  
  return true;
}

// Track Buffer allocations (common source of leaks)
createAllocationTracker('Buffer');

Integrating with APM Solutions

Modern Application Performance Monitoring tools can help detect and diagnose memory leaks:

// Example with Datadog APM
const tracer = require('dd-trace').init();
const memoryCheckInterval = setInterval(() => {
  const memUsage = process.memoryUsage();
  
  // Record memory metrics
  tracer.gauge('nodejs.memory.heap.used', memUsage.heapUsed);
  tracer.gauge('nodejs.memory.heap.total', memUsage.heapTotal);
  tracer.gauge('nodejs.memory.rss', memUsage.rss);
  tracer.gauge('nodejs.memory.external', memUsage.external);
  
  // Add heap usage to all spans for correlation
  tracer.scope().active()?.setTag('memory.heap.used_mb', Math.round(memUsage.heapUsed / 1024 / 1024));
}, 10000);

memoryCheckInterval.unref();

Case Study: Solving a Production Memory Leak

Let’s walk through an actual memory leak we encountered and resolved in a high-traffic production service.

Symptoms

Our payment processing service was experiencing:

  • Memory growth of approximately 50MB per hour
  • Eventual crashes after 2-3 days of uptime
  • No clear pattern in monitoring tools

Initial Investigation

  1. We deployed a canary instance with heap snapshot capture on SIGUSR2
  2. After allowing the leak to progress for 4 hours, we captured a heap snapshot
  3. We forced garbage collection and took another snapshot 1 hour later

Diagnosis

Comparing snapshots revealed:

  • A growing number of RequestContext objects (our custom class)
  • Each instance retained ~500KB of memory
  • The retaining path showed they were being held by an event emitter

Looking at the heap, we found:

Array(29653) → EventEmitter.listeners[0:n] → bound processRequest → closure(RequestContext)

Root Cause

The issue was in our transaction processing code:

// The problematic code
class TransactionProcessor {
  constructor(eventBus) {
    this.eventBus = eventBus;
  }
  
  processTransaction(transaction) {
    const context = new RequestContext(transaction);
    
    // The memory leak! This listener was never removed
    this.eventBus.on('response', (data) => {
      context.handleResponse(data);
      // Context should be garbage collected here, but the closure
      // in this event listener keeps it in memory
    });
    
    return context.startProcessing();
  }
}

The Fix

We restructured the code to properly clean up event listeners:

class TransactionProcessor {
  constructor(eventBus) {
    this.eventBus = eventBus;
  }
  
  processTransaction(transaction) {
    const context = new RequestContext(transaction);
    
    // Create a handler function we can reference later
    const responseHandler = (data) => {
      context.handleResponse(data);
      
      // Remove the listener after the response is processed
      this.eventBus.removeListener('response', responseHandler);
    };
    
    // Add the listener
    this.eventBus.on('response', responseHandler);
    
    // Add a timeout safeguard
    const timeoutId = setTimeout(() => {
      this.eventBus.removeListener('response', responseHandler);
    }, 30000); // 30 second maximum waiting time
    
    // Ensure the timeout is cleared
    context.once('complete', () => clearTimeout(timeoutId));
    
    return context.startProcessing();
  }
}

Results

After deploying this fix:

  • Memory usage stabilized at ~300MB
  • No restarts were needed for over 90 days
  • Request latency improved by 12% due to less GC activity

Preventative Strategies

Memory Leak Prevention Checklist

Implement these practices to prevent memory leaks before they occur:

  1. Bounded Caches: Always use size-limited caches with TTL eviction
  2. Event Listener Hygiene: Track all listeners and remove them when no longer needed
  3. Cleanup Middleware: Implement cleanup for request-specific resources
  4. Memory Usage CI Tests: Add memory growth tests to CI pipelines
  5. WeakMap and WeakSet: Use weak references for caches and lookups when appropriate
  6. Clear Timeouts and Intervals: Always store and clear timer references
  7. Explicit Cleanup Methods: Implement .dispose() or .cleanup() methods on classes with complex resources

Implementing Memory Leak Tests

Add automated tests that can catch potential memory leaks:

const memwatch = require('@airbnb/node-memwatch');

describe('Memory leak tests', () => {
  it('should not leak memory when processing many requests', async () => {
    // Force garbage collection before starting
    if (global.gc) global.gc();
    
    // Take a heap snapshot
    const baseHeap = memwatch.HeapDiff.start();
    
    // Perform operations that might leak
    for (let i = 0; i < 1000; i++) {
      await processRequest(generateMockRequest());
    }
    
    // Force garbage collection after operations
    if (global.gc) global.gc();
    
    // Check memory growth
    const heapDiff = baseHeap.end();
    
    // Allow some small growth but fail on significant increases
    expect(heapDiff.change.size_bytes).to.be.lessThan(1024 * 1024); // Max 1MB growth
  });
});

Conclusion

Debugging memory leaks in Node.js requires a systematic approach that combines proper monitoring, tools like heap snapshots, and a deep understanding of common leak patterns. By implementing the techniques outlined in this guide, you’ll be equipped to tackle even the most challenging memory issues in your production applications.

Remember that the best fix for a memory leak is preventing it in the first place. Establish coding patterns and reviews that catch potential memory issues early, and build memory testing into your CI pipeline to maintain application health over time.


About the Author: Rizqi Mulki is a Principal Node.js Engineer specializing in high-performance, mission-critical applications. With experience managing Node.js services processing millions of transactions daily, Rizqi Mulki has developed deep expertise in performance optimization and debugging production issues at scale.

Tags: Node.js, Memory Leaks, Performance, Debugging, EventEmitter, Garbage Collection, Heap Snapshots, Backend Development


Comments

Leave a Reply

Your email address will not be published. Required fields are marked *

CAPTCHA ImageChange Image