clockworklabs/SpacetimeDB · error · Error

Unsubscribe has already been called

Error message

Unsubscribe has already been called

What it means

SubscriptionHandleImpl.unsubscribe consumes the handle: the first call unregisters the query set and schedules the Unsubscribe message; the private #unsubscribeCalled flag makes any second call throw. This is use-once semantics - the handle cannot be unsubscribed twice.

Source

Thrown at crates/bindings-typescript/src/sdk/subscription_builder_impl.ts:220

          onError(ctx, error);
        }
      }
    );
    this.#querySetId = this.db.registerSubscription(
      this,
      this.#emitter,
      querySql
    );
  }

  /**
   * Consumes self and issues an `Unsubscribe` message,
   * removing this query from the client's set of subscribed queries.
   * It is only valid to call this method if `is_active()` is `true`.
   */
  unsubscribe(): void {
    if (this.#unsubscribeCalled) {
      throw new Error('Unsubscribe has already been called');
    }
    this.#unsubscribeCalled = true;
    this.db.unregisterSubscription(this.#querySetId);
    this.#emitter.on(
      'end',
      (_ctx: SubscriptionEventContextInterface<RemoteModule>) => {
        this.#endedState = true;
        this.#activeState = false;
      }
    );
  }

  /**
   * Unsubscribes and also registers a callback to run upon success.
   * I.e. when an `UnsubscribeApplied` message is received.
   *
   * If `Unsubscribe` returns an error,
   * or if the `on_error` callback(s) are invoked before this subscription would end normally,

View on GitHub (pinned to 524b4487d9)

Solutions

  1. Track whether you already unsubscribed (a boolean next to the handle) and guard the call
  2. In shared-handle setups, unsubscribe from exactly one owner (or reference-count it yourself)
  3. In React StrictMode, keep the handle in a ref and make cleanup idempotent with your own flag

Example fix

// before
useEffect(() => {
  const h = builder.subscribe(qs);
  return () => h.unsubscribe(); // called twice under StrictMode -> throws
}, []);

// after
const unsubRef = useRef(false);
useEffect(() => {
  const h = builder.subscribe(qs);
  return () => {
    if (unsubRef.current) return;
    unsubRef.current = true;
    h.unsubscribe();
  };
}, []);
Defensive patterns

Strategy: validation

Validate before calling

let unsubscribed = false;
const safeUnsubscribe = () => {
  if (unsubscribed || handle.isEnded()) return;
  unsubscribed = true;
  handle.unsubscribe();
};

Try / catch

try {
  handle.unsubscribe();
} catch (e) {
  if (e instanceof Error && e.message === 'Unsubscribe has already been called') {
    // benign double-teardown (e.g. StrictMode): ignore
  } else throw e;
}

Prevention

When it happens

Trigger: Calling unsubscribe() twice on the same handle; calling unsubscribe() after already calling unsubscribeThen() on the same handle; React StrictMode double-invoking an effect cleanup that calls unsubscribe.

Common situations: Dev-mode double effect cleanup (StrictMode runs mount/unmount twice); error-handling paths that unsubscribe again after a teardown path already did; multiple components sharing one handle and each unsubscribing on unmount.

Related errors


AI-assisted analysis of clockworklabs/SpacetimeDB@524b4487d9 (2026-08-16). Data as JSON: /api/errors/8b54441c5c125d77. Report an issue: GitHub.