clockworklabs/SpacetimeDB · error · Error

Unsubscribe has already been called

Error message

Unsubscribe has already been called

What it means

SubscriptionHandleImpl.unsubscribe consumes the handle: it flips #unsubscribeCalled, unregisters the query set, and listens for the end event. A second call on the same handle throws because the Unsubscribe message was already issued. Note the handle's active state only flips to false when the end event arrives, so isActive() alone does not prove unsubscribe was not already called.

Solutions

  1. Unsubscribe exactly once per handle from a single owner; null the handle ref after calling unsubscribe so cleanup becomes a no-op
  2. Keep your own alreadyUnsubscribed flag; isActive() is not sufficient because it stays true until the end event fires
  3. In React, unsubscribe only in the effect cleanup, not also in an explicit handler sharing the handle

Example fix

// before
useEffect(() => {
  const sub = ...;
  return () => sub.unsubscribe(); // StrictMode runs this twice -> throws
}, []);

// after
const subRef = useRef<SubscriptionHandleImpl<any> | null>(null);
useEffect(() => {
  subRef.current = ...;
  return () => {
    subRef.current?.unsubscribe();
    subRef.current = null;
  };
}, []);
Defensive patterns

Strategy: validation

Validate before calling

// unsubscribe() consumes the handle immediately; isActive() stays true until the
// 'end' event, so track consumption yourself.
if (subRef.current) {
  subRef.current.unsubscribe();
  subRef.current = null; // second cleanup is now a no-op
}

Prevention

When it happens

Trigger: Calling unsubscribe() twice on the same SubscriptionHandleImpl; calling unsubscribeThen() after unsubscribe() on the same handle; React StrictMode dev double-mount running an effect cleanup that unsubscribes twice.

Common situations: React 18 StrictMode double-invoking cleanup in development; unsubscribe triggered both by component unmount and by an explicit user action; retry/teardown logic that re-runs the same cleanup path.

Understand the failure class

Background: "Invalid state transition" errors: "status must be X, actually Y", "already rejected/charging/uninstalled", "cannot ... while running" — what they mean when a library rejects your call — this error's family across 31 libraries.

Related errors


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

Appendix: 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 6dee26c6ef)