Fixing Web3 SDK Memory Leaks in Node.js Services
Let us be honest with each other: memory leaks in long-running RPC daemons are one of the most insidious problems in Web3 backend development. They do not crash your service immediately. They whisper. They grow slowly. And because Web3 SDKs like Ethers.js and Web3.js maintain their own internal caches, event subscription maps, and WebSocket channels, the usual Node.js debugging intuitions often fall short. The good news is that the pattern is well-understood once you know where to look, and every leak we have encountered in production RPC services traces back to one of a handful of root causes. Let us dive in and squash them properly.
Identifying the Source: EventEmitter Bloat and Listener Accumulation
Here is the question that saves you the most time: how many listeners does your Web3 provider currently have? If you do not know the answer, there is a strong chance that number is climbing right now, silently, in the background.
Both Web3.js and Ethers.js use Node.js `EventEmitter` under the hood to manage event subscriptions. Every time you call `contract.on('Transfer', callback)` or subscribe to `provider.on('block', handler)`, a listener is attached to an internal emitter. The SDKs maintain their own subscription caches on top of Node.js native emitters, and if those listeners are never explicitly removed — when a component unmounts, a service restarts a polling loop, or a WebSocket reconnects — they accumulate.
You will often see the warning before you see the crash:
MaxListenersExceededWarning: Possible EventEmitter memory leak detected.
11 transfer listeners added to [EventEmitter].
This warning fires once by default when an emitter exceeds ten listeners. Many developers suppress it by calling `emitter.setMaxListeners(50)` and move on. Please do not do that. The warning is not the problem — it is the messenger. Raising the limit just lets the leak grow larger before anyone notices.
The fix is structural. Every place where you attach a listener to a provider or a contract instance needs a corresponding teardown path. Here is the pattern we use in production services:
const provider = new ethers.WebSocketProvider(wsUrl);
// Attach your listeners
provider.on('block', handleNewBlock);
contract.on('Transfer', handleTransfer);
// Later, when the service shuts down or restarts the subscription:
provider.removeAllListeners();
contract.removeAllListeners();
// For more surgical control, use.off() with a named function:
contract.off('Transfer', handleTransfer);The critical insight is that `removeAllListeners()` does not just detach callbacks from the JavaScript emitter — it also tells the underlying Web3 provider to clean up its internal subscription map. Without this call, Ethers.js in particular will keep references to every callback and every decoded event object in its cache, even after the WebSocket reconnects. Over hours or days of operation with high event throughput, this turns into megabytes of retained objects per subscription cycle.
Every listener you attach without a corresponding `.off()` or `.removeAllListeners()` is a small promise your service makes to the garbage collector — and then breaks.
Analyzing Heap Snapshots to Pinpoint Retained Web3 Objects
When the monitoring dashboard shows memory trending upward but the EventEmitter count looks reasonable, it is time to go deeper. This is where heap snapshots become your best friend, and honestly, they are far less intimidating than most developers expect.
Node.js gives you the `v8` module built in — no external dependencies needed. You can trigger a snapshot programmatically at any point in your service lifecycle:
const v8 = require('v8');
const fs = require('fs');
function captureHeap() {
const snapshotStream = v8.writeHeapSnapshot();
console.log(`Heap snapshot written to: ${snapshotStream}`);
return snapshotStream;
}
// Call this when memory looks suspicious:
captureHeap();This generates a `.heapsnapshot` file. Open Chrome DevTools (yes, Chrome — not Node inspector), navigate to the Memory tab, load the snapshot, and switch to the "Comparison" or "Containment" view. You are looking for objects that should have been collected but were not. Specifically, in a Web3 RPC service, you want to filter for instances of classes like `WebSocket`, `JsonRpcProvider`, `Contract`, `EventDescription`, and anything under the `ethers` or `web3` namespace.
The pattern we see most often is a retained tree that looks like this: a `WebSocket` instance holds a reference to a `JsonRpcProvider`, which holds a `SubscriptionManager`, which holds an array of `Listener` objects, each of which holds a closure, which holds a decoded `BigNumber` or `Log` object from a previous event. The whole chain stays alive because that initial `WebSocket` was never properly destroyed — only abandoned.
Take two snapshots at different times — one when memory is low, one after it has grown — and compare them. Chrome DevTools will show you exactly which object classes increased in count and retained size. If you see `ethers.SocketProvider` or internal `web3.eth.Contract` instances growing without bound, you have found your leak.
Managing WebSocket Lifecycle and Heartbeat Cleanup
WebSocket connections are the circulatory system of any real-time Web3 service, and they are also the single most common source of memory leaks we have seen in production. The issue is not the WebSocket itself — it is the lifecycle mismanagement that happens around it.
When you create a `WebSocketProvider`, the SDK opens a persistent WebSocket connection to your RPC endpoint and registers internal ping/pong handlers to keep the connection alive. If the connection drops and the SDK silently reconnects (which Ethers.js v6 does by default), it creates a new provider instance internally while potentially retaining references to the old one's subscription state. The old socket's event handlers, the stale subscription IDs, the orphaned listener closures — they all hang around in memory.
The fix requires explicit lifecycle management:
let provider;
function createProvider() {
provider = new ethers.WebSocketProvider(wsUrl);
provider.websocket.on('close', (code, reason) => {
console.log(`WebSocket closed: ${code} ${reason}`);
// Do NOT just create a new provider here.
// First, clean up the old one fully.
provider.removeAllListeners();
provider.destroy();
// Now create fresh:
setTimeout(() => createProvider(), 3000);
});
provider.websocket.on('error', (err) => {
console.error('WebSocket error:', err.message);
provider.removeAllListeners();
provider.destroy();
});
}The `.destroy()` method is the piece most developers miss. It is a synchronous call on `WebSocketProvider` that closes the underlying socket and clears the internal subscription map. Without it, calling `removeAllListeners()` cleans up the JavaScript side but leaves the native socket descriptor and its associated buffers lingering.
Pay special attention to the heartbeat mechanism. If your RPC provider (Alchemy, Infura, QuickNode, etc.) sends ping frames and your service does not respond with pong — perhaps because the event loop is blocked or the socket is in a half-open state — the connection becomes stale. The SDK may not detect this immediately. Meanwhile, every event the provider attempted to push during the stale period can accumulate in a write buffer that never gets flushed. We have measured services where a 30-second stale WebSocket window retained 15 MB of unprocessed binary frames.
A WebSocket that appears connected but is not exchanging pings is a memory leak wearing a disguise.
Monitoring heapUsed and External Memory Metrics in Production
You cannot fix what you cannot see, and in a Node.js Web3 service, the most important things to see are two numbers from `process.memoryUsage()`.
const mem = process.memoryUsage();
console.log({
heapUsed: `${(mem.heapUsed / 1024 / 1024).toFixed(1)} MB`,
heapTotal: `${(mem.heapTotal / 1024 / 1024).toFixed(1)} MB`,
external: `${(mem.external / 1024 / 1024).toFixed(1)} MB`,
rss: `${(mem.rss / 1024 / 1024).toFixed(1)} MB`
});Here is what each number tells you in the context of Web3 SDK leaks:
| Metric | What it tracks | Why it matters for Web3 services |
|---|---|---|
| `heapUsed` | V8-allocated JavaScript objects | Primary leak indicator — if this trends upward over hours without settling, you have retained objects |
| `external` | C++ objects bound to JS (Buffers, native bindings) | WebSocket frames, TLS buffers from RPC connections live here — spikes often mean socket leak |
| `heapTotal` | Total V8 heap including unused space | V8 is lazy about returning memory to the OS; track this alongside heapUsed |
| `rss` | Resident Set Size (total process memory) | The number your container orchestrator actually cares about — if this hits your limit, you are OOM-killed |
The rule of thumb: if `heapUsed` grows linearly with uptime and never plateaus after a V8 garbage collection cycle (you can force one with `global.gc()` if you start Node with `--expose-gc`), you have a genuine leak. V8's garbage collector is lazy by design — it will not compact memory until it detects pressure — so a temporarily elevated `heapUsed` is not necessarily a leak. The signal is a sustained upward trend across multiple GC cycles.
We recommend logging these metrics every 60 seconds in production and feeding them into a time-series store — Prometheus paired with Grafana works well for this, as does a hosted solution like Datadog if your team already runs it. The real value comes from correlating memory trends with RPC request volume and blockchain event frequency across your fleet, so you can spot which service instance is leaking before it cascades.
Set alerts for two thresholds: `heapUsed` exceeding 70% of your container memory limit, and `external` memory growing faster than `heapUsed` — the latter almost always points to unmanaged socket or buffer retention rather than application-level object leaks.
Refactoring Provider Instances for Graceful Service Restarts
Here is the uncomfortable truth that took us longer to accept than it should have: if your service's primary strategy for dealing with memory growth is "restart the container when it gets too big," you do not have a monitoring solution — you have a fire alarm. Restarting is mitigation. Fixing the leak is the resolution.
The most robust pattern we have implemented for long-running Web3 services is a structured provider lifecycle manager. The idea is simple: treat your Web3 provider as a resource with an explicit birth, life, and death — not as a singleton you create once and forget.
class ProviderManager {
constructor(wsUrl) {
this.wsUrl = wsUrl;
this.provider = null;
this.contracts = new Map();
}
async connect() {
this.provider = new ethers.WebSocketProvider(this.wsUrl);
await this.provider.ready;
this.provider.on('error', () => this.gracefulRestart());
}
registerContract(address, abi) {
const contract = new ethers.Contract(address, abi, this.provider);
this.contracts.set(address, contract);
return contract;
}
async gracefulRestart() {
// Detach every contract from the provider first
for (const [addr, contract] of this.contracts) {
contract.removeAllListeners();
}
this.contracts.clear();
// Now destroy the provider
this.provider.removeAllListeners();
this.provider.destroy();
this.provider = null;
// Re-establish everything
await this.connect();
// Re-register contracts and re-subscribe as needed
}
async shutdown() {
for (const [, contract] of this.contracts) {
contract.removeAllListeners();
}
this.contracts.clear();
this.provider.removeAllListeners();
this.provider.destroy();
}
}
// Graceful shutdown on process signals
process.on('SIGTERM', async () => {
await manager.shutdown();
process.exit(0);
});Notice the order of operations in `gracefulRestart`: contracts first, then provider. This matters because each contract holds a reference back to the provider, and if you destroy the provider before detaching the contracts, the contracts' cleanup calls may throw or fail silently, leaving their internal listeners attached to a dead emitter.
Register a `SIGTERM` and `SIGINT` handler that calls `shutdown()`. In containerized environments (Docker, Kubernetes), these signals arrive when the orchestrator wants your pod to terminate gracefully. Without the handler, your process dies mid-operation, and every provider, contract, and WebSocket in memory becomes a leak on the next startup if the SDK retains state in module-level caches.
A Parting Thought
Memory leaks in Web3 services are not a sign that you wrote bad code. They are a natural consequence of building on top of SDKs that manage complex, stateful, network-bound resources — and those SDKs do not always make their cleanup requirements obvious. The fact that you are here, reading about heap snapshots and `removeAllListeners()` and `process.memoryUsage()`, means you are already past the hardest part: accepting that the leak exists and deciding to fix it properly.
The code patterns in this article are battle-tested in production RPC daemons processing thousands of events per hour. Fork them, adapt them, break them, and tell us what you find. Open an issue on our GitHub repository if you discover a leak pattern we have not covered — the Web3 developer tooling ecosystem gets better every time someone shares what they learned the hard way.




