{"record":{"id":"8b54441c5c125d77","repo":"clockworklabs/SpacetimeDB","slug":"unsubscribe-has-already-been-called","errorCode":null,"errorMessage":"Unsubscribe has already been called","messagePattern":"Unsubscribe has already been called","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"crates/bindings-typescript/src/sdk/subscription_builder_impl.ts","lineNumber":220,"sourceCode":"          onError(ctx, error);\n        }\n      }\n    );\n    this.#querySetId = this.db.registerSubscription(\n      this,\n      this.#emitter,\n      querySql\n    );\n  }\n\n  /**\n   * Consumes self and issues an `Unsubscribe` message,\n   * removing this query from the client's set of subscribed queries.\n   * It is only valid to call this method if `is_active()` is `true`.\n   */\n  unsubscribe(): void {\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      }\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,","sourceCodeStart":202,"sourceCodeEnd":238,"githubUrl":"https://github.com/clockworklabs/SpacetimeDB/blob/524b4487d949b61a07d4f39c862d1290259dfd20/crates/bindings-typescript/src/sdk/subscription_builder_impl.ts#L202-L238","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","solutions":["Track whether you already unsubscribed (a boolean next to the handle) and guard the call","In shared-handle setups, unsubscribe from exactly one owner (or reference-count it yourself)","In React StrictMode, keep the handle in a ref and make cleanup idempotent with your own flag"],"exampleFix":"// before\nuseEffect(() => {\n  const h = builder.subscribe(qs);\n  return () => h.unsubscribe(); // called twice under StrictMode -> throws\n}, []);\n\n// after\nconst unsubRef = useRef(false);\nuseEffect(() => {\n  const h = builder.subscribe(qs);\n  return () => {\n    if (unsubRef.current) return;\n    unsubRef.current = true;\n    h.unsubscribe();\n  };\n}, []);","handlingStrategy":"validation","validationCode":"let unsubscribed = false;\nconst safeUnsubscribe = () => {\n  if (unsubscribed || handle.isEnded()) return;\n  unsubscribed = true;\n  handle.unsubscribe();\n};","typeGuard":null,"tryCatchPattern":"try {\n  handle.unsubscribe();\n} catch (e) {\n  if (e instanceof Error && e.message === 'Unsubscribe has already been called') {\n    // benign double-teardown (e.g. StrictMode): ignore\n  } else throw e;\n}","preventionTips":["Wrap handle teardown in one idempotent helper and call only that helper","In React StrictMode, guard effect cleanup with a ref flag","Give each subscription exactly one owner responsible for unsubscribing"],"tags":["subscription","lifecycle","unsubscribe","typescript"],"backgroundTag":"double-unsubscribe","analyzedSha":"524b4487d949b61a07d4f39c862d1290259dfd20","analyzedAt":"2026-08-16T23:58:54.611Z","schemaVersion":2},"datasetVersion":"2026-08-17T04:17:16.089Z"}