Skip to content

WebSocketProvider silently loses newHeads subscription after once("block") listener fires #5121

Description

@amlord

Describe the bug

AbstractProvider.emit() tears down "block" and "pending" subscriptions when the last listener is removed. This happens after every waitForTransaction call, which internally uses once("block", ...) — when the receipt is found the listener fires, listener count drops to 0, and emit() calls subscriber.stop() (sending eth_unsubscribe) then deletes the subscription from #subs.

The next waitForTransaction / once("block") call re-creates the subscription via eth_subscribe("newHeads"). On some nodes (parity / OpenEthereum), this re-subscribe silently fails — the RPC returns a subscription ID but no newHeads messages are ever delivered. The provider appears healthy (RPC calls work fine) but is permanently deaf to new blocks.

Reproduction

  1. Connect a WebSocketProvider to a parity/OpenEthereum node
  2. Call provider.waitForTransaction(hash) — succeeds, receipt returned
  3. Call provider.waitForTransaction(hash2) — hangs forever (or times out)

Between steps 2 and 3, the provider sent eth_unsubscribe + eth_subscribe("newHeads"). The re-subscribe silently failed.

Root cause

abstract-provider.js, emit() method (~line 1102 in CJS, ~1098 in ESM):

if (sub.listeners.length === 0) {
    if (sub.started) {
        sub.subscriber.stop();   // sends eth_unsubscribe
    }
    this.#subs.delete(sub.tag);  // removes subscription entirely
}

This teardown makes sense for event-based subscriptions (contract logs, etc.) but is harmful for "block" and "pending" which are singleton infrastructure subscriptions that should stay alive for the provider's lifetime.

Suggested fix

Skip teardown for "block" and "pending" subscription tags when listener count hits 0:

         if (sub.listeners.length === 0) {
-            if (sub.started) {
-                sub.subscriber.stop();
+            if (sub.tag === "block" || sub.tag === "pending") {
+                // no-op: keep subscriber running
+            }
+            else {
+                if (sub.started) {
+                    sub.subscriber.stop();
+                }
+                this.#subs.delete(sub.tag);
             }
-            this.#subs.delete(sub.tag);
         }

Full patch (CJS + ESM) below.

Environment

  • ethers: 6.14.4 (also confirmed in 6.16.0 — same code)
  • Node: v22+
  • Ethereum node: parity / OpenEthereum (likely affects any node where rapid unsubscribe/resubscribe is racy)

Patch

diff --git a/node_modules/ethers/lib.commonjs/providers/abstract-provider.js b/node_modules/ethers/lib.commonjs/providers/abstract-provider.js
--- a/node_modules/ethers/lib.commonjs/providers/abstract-provider.js
+++ b/node_modules/ethers/lib.commonjs/providers/abstract-provider.js
@@ -1102,10 +1102,23 @@ class AbstractProvider {
             return !once;
         });
         if (sub.listeners.length === 0) {
-            if (sub.started) {
-                sub.subscriber.stop();
+            // Keep "block" and "pending" subscriptions alive even when all
+            // listeners have been removed.  Tearing them down sends
+            // eth_unsubscribe and forces a new eth_subscribe on the next
+            // once("block") call.  Re-subscribing is racy on some nodes
+            // (parity / OpenEthereum) and silently fails, leaving the
+            // provider unable to emit block events while still responding
+            // to ordinary RPC calls.
+            if (sub.tag === "block" || sub.tag === "pending") {
+                // no-op: keep subscriber running
+            }
+            else {
+                if (sub.started) {
+                    sub.subscriber.stop();
+                }
+                this.#subs.delete(sub.tag);
             }
-            this.#subs.delete(sub.tag);
         }
         return (count > 0);
     }
diff --git a/node_modules/ethers/lib.esm/providers/abstract-provider.js b/node_modules/ethers/lib.esm/providers/abstract-provider.js
--- a/node_modules/ethers/lib.esm/providers/abstract-provider.js
+++ b/node_modules/ethers/lib.esm/providers/abstract-provider.js
@@ -1098,10 +1098,23 @@ export class AbstractProvider {
             return !once;
         });
         if (sub.listeners.length === 0) {
-            if (sub.started) {
-                sub.subscriber.stop();
+            // Keep "block" and "pending" subscriptions alive even when all
+            // listeners have been removed.  Tearing them down sends
+            // eth_unsubscribe and forces a new eth_subscribe on the next
+            // once("block") call.  Re-subscribing is racy on some nodes
+            // (parity / OpenEthereum) and silently fails, leaving the
+            // provider unable to emit block events while still responding
+            // to ordinary RPC calls.
+            if (sub.tag === "block" || sub.tag === "pending") {
+                // no-op: keep subscriber running
+            }
+            else {
+                if (sub.started) {
+                    sub.subscriber.stop();
+                }
+                this.#subs.delete(sub.tag);
             }
-            this.#subs.delete(sub.tag);
         }
         return (count > 0);
     }

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    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