Skip to content

subscription/index.ts L89-92: disposers and subscriptions Maps not cleared on last-subscriber teardown #4261

@Nexory

Description

@Nexory

subscription/index.ts L89-92: disposers and subscriptions Maps not cleared on last-subscriber teardown

When the last subscriber unmounts, the cleanup callback calls dispose() but never removes the stale entries from the subscriptions and disposers Maps. In a long-lived app that cycles through many distinct subscription keys (e.g. paginated feeds, rotating live-data channels), both Maps accumulate one dead entry per unique key and grow without bound.

Affected code

File: src/subscription/index.ts, lines 84-93

return () => {
  const count = subscriptions.get(subscriptionKey)! - 1

  subscriptions.set(subscriptionKey, count)

  // Dispose if it's the last one.
  if (!count) {
    const dispose = disposers.get(subscriptionKey)
    dispose?.()
    // <-- disposers and subscriptions entries are never deleted here
  }
}

Why this is a bug

subscriptions (ref-count Map) and disposers (cleanup function Map) are allocated once per cache boundary and live for the lifetime of the cache object. Every key that ever passes through useSubscription leaves a permanent entry after its last subscriber unmounts:

  • subscriptions keeps the key mapped to 0.
  • disposers keeps the key mapped to the (now-called) dispose function, preventing the function object from being garbage-collected.

In applications with a large or unbounded set of subscription keys (infinite scroll, user-navigated channels, rotating market tickers, etc.) this is a straightforward memory leak. The leak is proportional to the number of distinct keys ever used, not the number of currently-active subscriptions.

A secondary issue: because the zero-count entry remains in subscriptions, a future remount of the same key reads refCount = 0 correctly (the || 0 fallback), so the functional behaviour is not broken -- only the memory footprint grows.

Repro scenario

// Rotate through N distinct keys over time.
// After unmounting each key the Maps retain stale entries.

function RotatingSubscription({ channel }: { channel: string }) {
  useSWRSubscription(channel, (key, { next }) => {
    const ws = new WebSocket(`wss://example.com/${key}`)
    ws.onmessage = (e) => next(null, e.data)
    return () => ws.close()
  })
  return null
}

// Mount/unmount with channel = "ch-0", "ch-1", ..., "ch-9999"
// After all unmounts: subscriptions.size === 10000, disposers.size === 10000

Suggested fix

Delete the entries from both Maps immediately after calling dispose():

if (!count) {
  const dispose = disposers.get(subscriptionKey)
  dispose?.()
  disposers.delete(subscriptionKey)      // <-- add
  subscriptions.delete(subscriptionKey)  // <-- add
}

This restores the Maps to a size proportional to the number of currently active subscription keys rather than all keys ever seen.

Reporter: Nexory

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions