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
- Unsubscribe exactly once per handle from a single owner; null the handle ref after calling unsubscribe so cleanup becomes a no-op
- Keep your own alreadyUnsubscribed flag; isActive() is not sufficient because it stays true until the end event fires
- 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
- Call unsubscribe exactly once per handle; null the ref afterwards
- Do not rely on isActive() to detect a prior unsubscribe call
- Give each subscription one owner (effect cleanup or controller), never both
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
- Subscription has already ended
- Could not find SpacetimeDB client! Did you forget to add a…
- Subscriptions must be SQL strings or typed queries
- Subscriptions must have at least one query
- useSpacetimeDB must be used within a SpacetimeDBProvider…
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)