clientID vs clientGroupID
plasma has two identity concepts. The names look alike — they’re not the same thing. Confusing them is one of the fastest ways to write a sync bug.
clientID — one per tab
Section titled “clientID — one per tab”Every browser tab that opens plasma gets a fresh clientID. A UUID
generated on first use, persisted to sessionStorage, and
survives a page reload — but not a new tab / new window.
The clientID is what the sync loop uses to:
- Partition the outbox. Every outbox entry lives under the
compound key
[clientID, mutationID]. Two tabs of the same user push their own outbox slice without stepping on each other. - De-duplicate on the server. The server tracks
last_mutation_idper (clientGroupID,clientID) tuple. A retried mutation with the same(clientGroupID, clientID, mutationID)is a no-op. - Track the pull cursor. Pull responses carry
lastMutationIDs: Record<clientID, mutationID>so each tab knows which of its own outbox entries the server has already processed.
sessionStorage is a per-tab store by design; opening a new tab
mints a new clientID and starts a fresh outbox. That’s a feature —
two tabs of the same user editing at once are treated as two
distinct clients, and their concurrent writes reconcile via the
normal sync path.
clientGroupID — one per installation
Section titled “clientGroupID — one per installation”Every PlasmaClient is constructed with a clientGroupID that you
supply:
createPlasmaClient({ ..., clientGroupID: currentUser.id,})Semantically, one clientGroupID = one logical installation of
your app for one user. Every tab that user opens uses the same
clientGroupID. The clientID distinguishes the tabs; the
clientGroupID groups them.
Server-side, clientGroupID is what:
- Scopes
auth.read/auth.write. Thectxyourauth()handler returns is stored per (clientGroupID,clientID) — so a mid-session logout that changes the effective user re-runs authorisation. - Bounds the change_log tail sent on pull. A client only sees
rows its
auth.readallowed for thatclientGroupID. - Anchors the causal cookie. The cookie tracks “which server
writes has this
clientGroupIDseen”; new tabs joining the same group inherit the current cookie via IDB.
When they diverge — the concrete effect
Section titled “When they diverge — the concrete effect”Two tabs of the same user, both signed in:
Tab A: clientGroupID: "user-42" clientID: "aaaa..."Tab B: clientGroupID: "user-42" clientID: "bbbb..."Tab A calls mutate("markDone", { id: "t1" }). Tab B calls
mutate("editTitle", { id: "t1", title: "new" }) at the same
instant.
- Both tabs push their mutation. Because the outbox is partitioned
by
clientID, the two outboxes don’t collide. - The server sees
(user-42, aaaa, 12)and(user-42, bbbb, 33)— two distinct mutations, both applied canonically. - The pull response tells Tab A “
aaaa’s mutation #12 is confirmed”; Tab A’s outbox drops that entry. - Both tabs pull the resulting server state and rebase their optimistic view against it.
If the two mutations touched the same row in ways that don’t
commute (both edit title), the server’s default reconciliation
(last-write wins by row_version order) picks a winner. Either
declare resolveConflict on the table, or use a CRDT column to
guarantee lossless merges. See Conflict resolution.
resetLocalState() rotates clientID
Section titled “resetLocalState() rotates clientID”client.resetLocalState() wipes the local IDB — base stores, user
stores, outbox, cookie, everything — and rotates the
clientID. This is deliberate: without rotation, the server’s
last_mutation_id watermark for the old clientID would still be
at N, so the first mutation the client sends after the reset (at
mutationID: 1) would get silently dropped as a duplicate.
The clientGroupID is unchanged.
What to read next
Section titled “What to read next”- Push, pull, rebase — how the identity plumbing feeds the sync loop
- Auth and permissions — writing
the server-side
auth()handler