Check existing issues
Viem Version
2.31.7
Current Behavior
When all listeners for a given observerId call unwatch(), the observe() function in src/utils/observe.ts removes individual callbacks via filter() (line 13), but never deletes the Map entry from listenersCache or cleanupCache. This leaves behind stale entries with empty arrays [] that accumulate indefinitely.
In a long-running Node.js server that creates many viem clients over time (e.g., createPublicClient, createClient), listenersCache grows without bound, causing a memory leak.
We confirmed this by inspecting the cache at runtime: thousands of entries with empty [] values, never garbage-collected because the Map holds strong references.
Expected Behavior
When the last listener for an observerId unsubscribes, the entry should be fully removed from both listenersCache and cleanupCache, not left as an empty array.
Steps To Reproduce
const path = require('path');
const viemObserve = require(
path.join(
path.dirname(require.resolve('viem/package.json')),
'_cjs/utils/observe.js'
)
);
const { listenersCache, cleanupCache } = viemObserve;
const { createPublicClient, http } = require('viem');
const { mainnet } = require('viem/chains');
// Simulate a server that polls block numbers on many short-lived clients
for (let i = 0; i < 100; i++) {
const client = createPublicClient({
chain: mainnet,
transport: http('https://rpc.ankr.com/eth'),
});
// watchBlockNumber calls observe() internally
const unwatch = client.watchBlockNumber({
onBlockNumber: () => {},
poll: true,
pollingInterval: 60_000,
});
// Immediately unwatch — simulates short-lived usage
unwatch();
}
console.log('listenersCache size:', listenersCache.size);
// Expected: 0 (all observers unsubscribed)
// Actual: 100 (empty arrays remain in the Map)
for (const [key, value] of listenersCache) {
console.log(` key=${key}, listeners=${value.length}`);
// Every entry shows listeners=0 — stale empty arrays
}
Anything else?
The fix would be to delete the Map entries in unsubscribe() when the filtered listeners array is empty. Something like:
const unsubscribe = () => {
const listeners = getListeners();
const remaining = listeners.filter((cb) => cb.id !== callbackId);
if (remaining.length === 0) {
listenersCache.delete(observerId);
cleanupCache.delete(observerId);
} else {
listenersCache.set(observerId, remaining);
}
};
Our current workaround is an hourly setInterval that sweeps listenersCache for empty arrays and deletes them, plus caching our viem clients to reduce the rate of accumulation.
Check existing issues
Viem Version
2.31.7
Current Behavior
When all listeners for a given
observerIdcallunwatch(), theobserve()function insrc/utils/observe.tsremoves individual callbacks viafilter()(line 13), but never deletes the Map entry fromlistenersCacheorcleanupCache. This leaves behind stale entries with empty arrays[]that accumulate indefinitely.In a long-running Node.js server that creates many viem clients over time (e.g.,
createPublicClient,createClient),listenersCachegrows without bound, causing a memory leak.We confirmed this by inspecting the cache at runtime: thousands of entries with empty
[]values, never garbage-collected because theMapholds strong references.Expected Behavior
When the last listener for an
observerIdunsubscribes, the entry should be fully removed from bothlistenersCacheandcleanupCache, not left as an empty array.Steps To Reproduce
Anything else?
The fix would be to delete the Map entries in
unsubscribe()when the filtered listeners array is empty. Something like:Our current workaround is an hourly
setIntervalthat sweepslistenersCache for empty arrays and deletes them, plus caching our viem clients to reduce the rate of accumulation.