Skip to content

observe() leaks empty arrays in listenersCache after all listeners unsubscribe #4390

@yaniferhaoui

Description

@yaniferhaoui

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions