{"record":{"id":"572d55eea11f336f","repo":"clockworklabs/SpacetimeDB","slug":"subscription-has-already-ended","errorCode":null,"errorMessage":"Subscription has already ended","messagePattern":"Subscription has already ended","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"crates/bindings-typescript/src/sdk/subscription_builder_impl.ts","lineNumber":247,"sourceCode":"      }\n    );\n  }\n\n  /**\n   * Unsubscribes and also registers a callback to run upon success.\n   * I.e. when an `UnsubscribeApplied` message is received.\n   *\n   * If `Unsubscribe` returns an error,\n   * or if the `on_error` callback(s) are invoked before this subscription would end normally,\n   * the `on_end` callback is not invoked.\n   *\n   * @param onEnd - Callback to run upon successful unsubscribe.\n   */\n  unsubscribeThen(\n    onEnd: (ctx: SubscriptionEventContextInterface<RemoteModule>) => void\n  ): void {\n    if (this.#endedState) {\n      throw new Error('Subscription has already ended');\n    }\n    if (this.#unsubscribeCalled) {\n      throw new Error('Unsubscribe has already been called');\n    }\n    this.#unsubscribeCalled = true;\n    this.db.unregisterSubscription(this.#querySetId);\n    this.#emitter.on(\n      'end',\n      (ctx: SubscriptionEventContextInterface<RemoteModule>) => {\n        this.#endedState = true;\n        this.#activeState = false;\n        onEnd(ctx);\n      }\n    );\n  }\n\n  /**\n   * True if this `SubscriptionHandle` has ended,","sourceCodeStart":229,"sourceCodeEnd":265,"githubUrl":"https://github.com/clockworklabs/SpacetimeDB/blob/6dee26c6efc2856793e12b148a59742964f5d783/crates/bindings-typescript/src/sdk/subscription_builder_impl.ts#L229-L265","documentation":"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).","triggerScenarios":"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.","commonSituations":"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.","solutions":["Check handle.isEnded() before calling unsubscribeThen — it reflects the ended flag exactly","Route all unsubscribe attempts for a handle through one owner so at most one teardown runs","Track subscription lifecycle in your own state machine when multiple callers may unsubscribe"],"exampleFix":"// before\nhandle.unsubscribeThen(onEnd); // throws if already ended\n\n// after\nif (!handle.isEnded()) {\n  handle.unsubscribeThen(onEnd);\n}","handlingStrategy":"validation","validationCode":"if (!handle.isEnded()) {\n  handle.unsubscribeThen(onEnd);\n}","typeGuard":null,"tryCatchPattern":null,"preventionTips":["Check isEnded() before unsubscribeThen","Funnel all teardown through a single owner per handle","Register your own onEnd/onError callbacks and use them to track lifecycle state"],"tags":["subscription","lifecycle","typescript","state-management"],"backgroundTag":"invalid-state-transition","analyzedSha":"6dee26c6efc2856793e12b148a59742964f5d783","analyzedAt":"2026-08-20T06:08:37.179Z","contentChangedAt":"2026-08-20T06:08:37.179Z","schemaVersion":2},"datasetVersion":"2026-09-23T08:17:48.524Z"}