Skip to content

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.

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_id per (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.

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. The ctx your auth() 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.read allowed for that clientGroupID.
  • Anchors the causal cookie. The cookie tracks “which server writes has this clientGroupID seen”; new tabs joining the same group inherit the current cookie via IDB.

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.

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.