Guide · Performance

MCP server memory leak debugging

A memory leak in an MCP server rarely crashes it immediately. Instead, the heap grows a few megabytes per hour until — six hours or three days later — the process exhausts available memory and the OOM killer terminates it. LLM clients receive a connection error. AliveMCP fires an alert. By then, the crash has already happened. This guide covers how to detect leaks early with process.memoryUsage() monitoring, how to isolate the retained objects with heap snapshots, and the four most common leak patterns in Node.js MCP servers.

TL;DR

Add a periodic process.memoryUsage().heapUsed log to your server. If it grows steadily over hours without flattening, you have a leak. Take two heap snapshots with node --inspect and Chrome DevTools (before load and after sustained load), then compare the "Objects allocated between snapshots" view. The object type with the highest count growth is usually your leak. Most MCP server leaks are: (1) event listeners added but never removed, (2) closures capturing large objects in Maps or Sets, (3) unbounded in-memory caches, (4) setInterval callbacks that reference accumulating data.

Detecting a leak with process.memoryUsage()

The first signal of a memory leak is heap growth that doesn't flatten. Add a periodic memory log to your server startup and watch it over time.

// src/server.ts — add memory logging at startup
const MEMORY_LOG_INTERVAL_MS = 60_000; // every minute

const memoryLogger = setInterval(() => {
  const { heapUsed, heapTotal, rss, external } = process.memoryUsage();
  console.log(JSON.stringify({
    level: 'info',
    event: 'memory_usage',
    heapUsedMB: (heapUsed / 1024 / 1024).toFixed(1),
    heapTotalMB: (heapTotal / 1024 / 1024).toFixed(1),
    rssMB: (rss / 1024 / 1024).toFixed(1),
    externalMB: (external / 1024 / 1024).toFixed(1),
    ts: new Date().toISOString(),
  }));
}, MEMORY_LOG_INTERVAL_MS);

// Unref so the interval doesn't prevent clean shutdown
memoryLogger.unref();

Healthy heap growth pattern: heapUsed climbs during request spikes, then falls after GC. After a few minutes of steady state, it stabilizes at a baseline. Leak pattern: heapUsed grows 1–5MB per minute steadily, GC does not bring it back to baseline, and each GC cycle's baseline is higher than the last.

The fields to watch:

FieldWhat it tracksLeak signal
heapUsedLive objects on the V8 heapSteady upward trend after GC
heapTotalV8 heap capacity (allocated from OS)Grows when heapUsed approaches it
rssResident set size (all memory including native)Grows for native module leaks (buffers, C++ objects)
externalMemory used by C++ objects bound to V8Grows for Buffer/TypedArray leaks

Heap snapshots with node --inspect

When memory logging confirms a leak, heap snapshots let you identify which objects are being retained. The workflow: take a snapshot at baseline, run sustained load for several minutes, take a second snapshot, then compare the two in Chrome DevTools to see which object types grew.

# Start your server with the inspector enabled
node --inspect src/server.js

# Chrome DevTools will show: "Debugger listening on ws://127.0.0.1:9229/..."
# Open Chrome: chrome://inspect → "Open dedicated DevTools for Node"
  1. In Chrome DevTools, open the Memory tab.
  2. Click Take snapshot to capture the baseline heap.
  3. Run load against your server for 5–10 minutes (use an InMemoryTransport loop or autocannon).
  4. Click Take snapshot again.
  5. Select the second snapshot, then in the dropdown change from "Summary" to "Comparison".
  6. Sort by "# New" (new object count) descending.

The objects with the highest new count that you recognize (your own classes, plain objects from your handlers) are the leak candidates. Click any object type to see the retainer chain — the path that keeps the object alive.

// Alternative: programmatic heap snapshot without opening Chrome
import v8 from 'v8';
import fs from 'fs';

// Write a heap snapshot to a file (can be opened in Chrome DevTools offline)
const snapshotPath = `/tmp/heap-${Date.now()}.heapsnapshot`;
const stream = v8.writeHeapSnapshot(snapshotPath);
console.log(`Heap snapshot written to: ${stream}`);

The four most common MCP server leak patterns

1. EventEmitter listeners not removed

Node.js EventEmitters warn when more than 10 listeners are added to a single event — this is the most common leak pattern. In MCP servers it appears when a new listener is registered on each incoming request or tool call without being removed afterward.

// Leak: adds a listener on every tool call, never removes it
server.setRequestHandler(CallToolRequestSchema, async (request) => {
  // BUG: new listener added on each call — memory grows with call count
  someEmitter.on('data', (chunk) => processChunk(chunk));
  const result = await fetchData();
  return { content: [{ type: 'text', text: JSON.stringify(result) }] };
});

// Fix: register listeners once at startup, or use .once() for per-call listeners
// and explicitly remove them in a finally block
server.setRequestHandler(CallToolRequestSchema, async (request) => {
  const controller = new AbortController();
  const handler = (chunk: Buffer) => processChunk(chunk);
  someEmitter.on('data', handler);
  try {
    const result = await fetchData({ signal: controller.signal });
    return { content: [{ type: 'text', text: JSON.stringify(result) }] };
  } finally {
    someEmitter.off('data', handler); // always remove
  }
});

2. Closure capturing large objects in a Map or Set

Closures in JavaScript retain references to everything in their enclosing scope. When closures are stored in a Map or Set that grows without bounds, every captured object is retained indefinitely.

// Leak: in-flight request map grows unbounded on error paths
const inflightRequests = new Map<string, { request: unknown; started: Date }>();

server.setRequestHandler(CallToolRequestSchema, async (request) => {
  const id = crypto.randomUUID();
  inflightRequests.set(id, { request, started: new Date() });
  try {
    const result = await processRequest(request);
    return result;
  } catch (err) {
    // BUG: error path forgets to delete from the Map
    throw err;
  }
  // Fix: always clean up in finally
  finally {
    inflightRequests.delete(id); // runs even on error
  }
});

3. Unbounded in-memory caches

An in-memory cache without eviction grows until the process runs out of memory. This is the most insidious leak because it works perfectly during development (small data) and fails in production (large data over many hours).

// Leak: unbounded Map used as cache
const resultCache = new Map<string, unknown>();

// Fix: use an LRU cache with a size cap
import { LRUCache } from 'lru-cache'; // npm install lru-cache

const resultCache = new LRUCache<string, unknown>({
  max: 1000,          // at most 1000 entries
  ttl: 1000 * 60 * 5, // entries expire after 5 minutes
  // Optional: size-based eviction
  maxSize: 50 * 1024 * 1024, // 50MB cap
  sizeCalculation: (value) => JSON.stringify(value).length,
});

4. setInterval retaining growing data

A setInterval callback that accumulates data without clearing it will grow indefinitely. Common examples: an array of metrics that appends on every tick, a Set of seen request IDs that never expires, or a rolling buffer that doesn't rotate.

// Leak: metrics array grows forever
const metrics: number[] = [];
setInterval(() => {
  metrics.push(Date.now()); // BUG: never emptied
}, 1000);

// Fix: use a fixed-size ring buffer or clear periodically
const MAX_METRICS = 3600; // keep last hour at 1-second granularity
const metrics: number[] = [];
setInterval(() => {
  metrics.push(Date.now());
  if (metrics.length > MAX_METRICS) metrics.shift(); // evict oldest
}, 1000);

WeakMap and WeakRef for leak-safe references

When you need to associate data with an object but don't want to prevent that object from being garbage-collected, use WeakMap instead of Map. A WeakMap key is held weakly — if no other reference to the key exists, the GC can collect both the key and the value.

// WeakMap: metadata per request object, GC-safe
const requestMetadata = new WeakMap<object, { startedAt: number; toolName: string }>();

server.setRequestHandler(CallToolRequestSchema, async (request) => {
  // The WeakMap entry is automatically collected when 'request' is GC'd
  requestMetadata.set(request, { startedAt: performance.now(), toolName: request.params.name });

  const result = await handleTool(request);

  const meta = requestMetadata.get(request);
  if (meta) {
    console.log(JSON.stringify({
      event: 'tool_call_complete',
      tool: meta.toolName,
      durationMs: performance.now() - meta.startedAt,
    }));
  }

  return result;
});

WeakRef is useful when you want to hold a reference to an object without preventing its collection — for example, a cache that prefers to keep an object alive but accepts it being collected under memory pressure. Call weakRef.deref() to get the object; returns undefined if it has been collected.

Reproducing leaks in tests

A memory leak is most reliably reproduced with a long-running loop that exercises the leaking handler. Combine with process.memoryUsage() assertions.

// test/memory-leak.test.ts
import { describe, it, expect } from 'vitest';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
import { createServer } from '../src/server.js';

describe('memory stability', () => {
  it('heap does not grow unboundedly over 5000 calls', async () => {
    const [serverTransport, clientTransport] = InMemoryTransport.createLinkedPair();
    const server = createServer();
    await server.connect(serverTransport);
    const client = new Client({ name: 'test', version: '1.0.0' }, { capabilities: {} });
    await client.connect(clientTransport);

    // Warm up and allow GC to establish baseline
    for (let i = 0; i < 100; i++) {
      await client.callTool({ name: 'search_documents', arguments: { query: 'warmup' } });
    }
    if (global.gc) global.gc(); // force GC if --expose-gc flag is set
    const heapBefore = process.memoryUsage().heapUsed;

    // Exercise the handler
    for (let i = 0; i < 5000; i++) {
      await client.callTool({ name: 'search_documents', arguments: { query: `item ${i}` } });
    }
    if (global.gc) global.gc();
    const heapAfter = process.memoryUsage().heapUsed;

    // Allow up to 10MB growth (for normal V8 heap fragmentation)
    const growthMB = (heapAfter - heapBefore) / 1024 / 1024;
    expect(growthMB).toBeLessThan(10);

    await client.close();
  });
});
# Run with --expose-gc to allow forced GC in the test
node --expose-gc node_modules/.bin/vitest run test/memory-leak.test.ts

What a memory leak looks like to external monitoring

A slowly leaking MCP server presents a gradual performance degradation before the final OOM crash. As heap pressure mounts, GC pauses become more frequent and longer — tool-call latency rises, p99 widens, and eventually the server becomes unresponsive as GC consumes all CPU. The final OOM kill is abrupt: all in-flight tool calls fail with a connection error. AliveMCP detects both symptoms: rising p99 latency shows up in probe timing before the crash; the crash itself fires an immediate down alert. This gives you two chances to catch the problem — once before it becomes an outage, and once as it happens.

Related questions

How do I take a heap snapshot in a Docker container?

Start the container with --inspect=0.0.0.0:9229 and expose port 9229: docker run -p 9229:9229 your-image node --inspect=0.0.0.0:9229 src/server.js. Then open Chrome DevTools and connect to localhost:9229. Alternatively, use the programmatic v8.writeHeapSnapshot() API to write the snapshot to a file, then copy it out with docker cp container:/tmp/heap.heapsnapshot ./heap.heapsnapshot and open it in Chrome DevTools offline.

Does the MCP SDK itself have known memory leaks?

No known permanent leaks in the current SDK. The SDK uses a RequestManager that tracks in-flight requests in a Map — these are cleaned up when responses arrive or the connection closes. If you observe heap growth that traces back to SDK internals in a heap snapshot, check whether you are closing connections properly after use (call client.close() in test teardown, transport cleanup in graceful shutdown). Unclosed connections leave entries in the RequestManager indefinitely.

What is the difference between a memory leak and high memory usage?

High memory usage is stable — the heap reaches a plateau and stays there, GC maintains it within a band. This is normal for servers with large caches, working sets, or many concurrent connections. A leak is unbounded growth — the heap never plateaus, it grows until the process crashes. The diagnostic question is: does heap usage stabilize after a period of load? If yes, it's high usage (consider adding more memory or shrinking caches). If it keeps climbing indefinitely, it's a leak.

Further reading