clockworklabs/SpacetimeDB · error · Error

Subscription has already ended

Error message

Subscription has already ended

What it means

unsubscribeThen registers an on-end callback and issues the unsubscribe; it first refuses to run when the subscription has already ended (#endedState becomes true when the end event fired, e.g. after UnsubscribeApplied was received or an error ended the subscription).

Solutions

  1. Check handle.isEnded() before calling unsubscribeThen — it reflects the ended flag exactly
  2. Route all unsubscribe attempts for a handle through one owner so at most one teardown runs
  3. Track subscription lifecycle in your own state machine when multiple callers may unsubscribe

Example fix

// before
handle.unsubscribeThen(onEnd); // throws if already ended

// after
if (!handle.isEnded()) {
  handle.unsubscribeThen(onEnd);
}
Defensive patterns

Strategy: validation

Validate before calling

if (!handle.isEnded()) {
  handle.unsubscribeThen(onEnd);
}

Prevention

When it happens

Trigger: Calling unsubscribeThen(cb) after the subscription already ended: a previous unsubscribe completed, or on_error ended the subscription, and a second teardown path calls unsubscribeThen on the same handle.

Common situations: Two teardown paths (component unmount and logout flow) both trying to end the same subscription; delayed async code calling unsubscribeThen after the end event already arrived.

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/572d55eea11f336f. Report an issue: GitHub.

Appendix: source

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

      }
    );
  }

  /**
   * 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,
   * the `on_end` callback is not invoked.
   *
   * @param onEnd - Callback to run upon successful unsubscribe.
   */
  unsubscribeThen(
    onEnd: (ctx: SubscriptionEventContextInterface<RemoteModule>) => void
  ): void {
    if (this.#endedState) {
      throw new Error('Subscription has already ended');
    }
    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;
        onEnd(ctx);
      }
    );
  }

  /**
   * True if this `SubscriptionHandle` has ended,

View on GitHub (pinned to 6dee26c6ef)