DEV Community

Cover image for Fix: A Missing .catch() That Crashes Your Entire Node.js Process (ethers.js WebSocket Provider)
Ethan Park
Ethan Park

Posted on

Fix: A Missing .catch() That Crashes Your Entire Node.js Process (ethers.js WebSocket Provider)

Summer Bug Smash: Clear the Lineup 🐛🛹

This is a submission for DEV's Summer Bug Smash: Clear the Lineup powered by Sentry.

Project Overview

ethers.js is one of the most widely used Ethereum libraries, with over 3 million weekly downloads on npm. It provides the core infrastructure that thousands of dApps, wallets, and blockchain tools depend on for interacting with EVM chains.

I discovered a bug in the WebSocketProvider that causes uncatchable unhandled rejections — silently crashing production Node.js processes with no way for application code to intercept the error.

Bug Fix or Performance Improvement

The Problem

When a WebSocket provider returns a JSON-RPC error to an eth_subscribe call (auth failure, 5xx behind a gateway, unsupported method), the rejection has no handler and becomes an unhandled rejection that crashes the process.

SocketSubscriber.start() stores the subscribe promise with only a .then():

// Before fix — provider-socket.ts line 63
start(): void {
    this.#filterId = this.#provider.send("eth_subscribe", this.filter).then((filterId) => {;
        this.#provider._register(filterId, this);
        return filterId;
    });
}
Enter fullscreen mode Exit fullscreen mode

start() returns void and #filterId is private — no rejection handler exists and none can be attached from outside. try/catch around contract.on() or provider.on() cannot catch it. The only place it surfaces is process.on('unhandledRejection').

For a long-running event listener, this turns a transient, retryable provider error into a hard process exit.

The Real-World Trigger

This was observed during a provider regional incident. The WebSocket handshake succeeds, the provider reports ready, then eth_subscribe is answered with an error:

Error: could not coalesce error (error={ "code": -32608, "message": "unknown response, status code: 500" },
  payload={ "method": "eth_subscribe", "params": [ "logs", { "address": [...] } ] })
Enter fullscreen mode Exit fullscreen mode

Code

PR: ethers-io/ethers.js#5180

The Fix (Diff)

     start(): void {
-        this.#filterId = this.#provider.send("eth_subscribe", this.filter).then((filterId) => {;
+        const promise = this.#provider.send("eth_subscribe", this.filter).then((filterId) => {
             this.#provider._register(filterId, this);
             return filterId;
+        }, (error) => {
+            if (this.#filterId === promise) { this.#filterId = null; }
+            this.#provider.emit("error", makeError("failed to subscribe", "UNKNOWN_ERROR", {
+                error, info: { filter: this.filter }
+            }));
+            return null as any;
         });
+        this.#filterId = promise;
     }

     stop(): void {
-        (<Promise<number>>(this.#filterId)).then((filterId) => {
-            if (this.#provider.destroyed) { return; }
-            this.#provider.send("eth_unsubscribe", [ filterId ]);
-        });
+        if (this.#filterId != null) {
+            this.#filterId.then((filterId) => {
+                if (filterId == null || this.#provider.destroyed) { return; }
+                this.#provider.send("eth_unsubscribe", [ filterId ]);
+            }).catch(() => { });
+        }
         this.#filterId = null;
     }
Enter fullscreen mode Exit fullscreen mode

My Improvements

Why .then(onFulfilled, onRejected) instead of .catch()

My first attempt used .catch() with a re-throw to keep the rejection chain:

.catch((error) => {
    this.#provider.emit("error", ...);
    throw error; // Re-throw to preserve the rejection
});
Enter fullscreen mode Exit fullscreen mode

This re-introduced the exact same bug. When .catch() re-throws, it creates a new rejected promise. Since nobody handles that new promise, you get another unhandled rejection — the same crash, just one step further down the chain.

The correct pattern is .then(onFulfilled, onRejected) where onRejected returns null to absorb the rejection. The promise fulfills with null instead of rejecting, so there's nothing unhandled.

Identity Guard for Promise Safety

The onRejected handler sets this.#filterId = null to clean up. But what if start() were called again before the first rejection settles? The stale handler would clobber the new subscription.

I added an identity guard:

if (this.#filterId === promise) { this.#filterId = null; }
Enter fullscreen mode Exit fullscreen mode

This ensures the handler only clears its own promise, never a subsequent one.

Hardening stop()

The original stop() had two issues:

  1. No null check on #filterId — calling .then() on null throws
  2. No .catch() — if the subscription had failed, the eth_unsubscribe call would also produce an unhandled rejection
  3. No guard against sending eth_unsubscribe with a null filter ID

Tests Added

Three tests with unhandledRejection process listeners that fail the test if any unhandled rejection occurs:

  1. eth_subscribe rejection emits provider "error" event with correct message
  2. stop() on a failed subscription does not throw
  3. stop() on a failed subscription does not send eth_unsubscribe

Closes #5178. Related to #3924 (the FilterIdSubscriber variant, labelled fixed/complete).

Top comments (0)