{"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/524b4487d949b61a07d4f39c862d1290259dfd20/crates/bindings-typescript/src/sdk/subscription_builder_impl.ts#L229-L265","documentation":"unsubscribeThen(onEnd) is only valid while the subscription has not ended. The #endedState flag becomes true when the 'end' event fires (UnsubscribeApplied received, or the subscription otherwise ended). Calling unsubscribeThen after that is a lifecycle violation and throws.","triggerScenarios":"Calling unsubscribeThen(cb) after the 'end' event already fired; calling it after a previous unsubscribe completed and applied; a late async callback (debounced unmount, delayed navigation) racing the server's UnsubscribeApplied.","commonSituations":"Component teardown racing an already-ended subscription; retry/reconnect logic calling unsubscribeThen on a stale handle after the connection dropped the subscription; user clicking 'unsubscribe' twice with the second handler firing after end.","solutions":["Check handle.isEnded() before calling unsubscribeThen","Register on_end via the builder/handle's event API instead of triggering teardown on an already-ended subscription","Clear pending async teardown callbacks (timers, debounces) when the subscription ends"],"exampleFix":"// before\nsetTimeout(() => handle.unsubscribeThen(onEnd), 5000); // may run after 'end' -> throws\n\n// after\nsetTimeout(() => {\n  if (!handle.isEnded()) handle.unsubscribeThen(onEnd);\n}, 5000);","handlingStrategy":"validation","validationCode":"if (!handle.isEnded()) {\n  handle.unsubscribeThen(onEnd);\n}","typeGuard":"function canUnsubscribeThen(h: { isEnded(): boolean; isActive(): boolean }): boolean {\n  return !h.isEnded() && h.isActive();\n}","tryCatchPattern":null,"preventionTips":["Check isEnded() before any deferred unsubscribeThen (timers, debounced unmount)","Cancel pending teardown callbacks when the subscription's end event fires"],"tags":["subscription","lifecycle","unsubscribe","typescript"],"backgroundTag":"invalid-subscription-state","analyzedSha":"524b4487d949b61a07d4f39c862d1290259dfd20","analyzedAt":"2026-08-16T23:58:54.611Z","schemaVersion":2},"datasetVersion":"2026-08-17T04:17:16.089Z"}